From be4031cb54d9bd88d1bad9cf993d27f914c767fa Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 29 Oct 2026 17:55:55 +0400 Subject: [PATCH] feat(runtime): propagate Control node stage changes --- crates/fparkan-runtime/src/lib.rs | 317 +++++++++++++++++- ...behavior_distributor_tick_native_oracle.cs | 39 +-- docs/appendices/knowledge-boundaries.md | 26 +- docs/appendices/script-vm.md | 38 ++- docs/tomes/06-behavior.md | 37 +- 5 files changed, 410 insertions(+), 47 deletions(-) diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 7ba114d..447e024 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -2240,6 +2240,32 @@ pub fn update_loaded_mission_control_node_stage( update_control_node_stage(table, key, update_effects) } +/// Updates one loaded Control node and then walks its provider descendants in +/// native depth-first order (`Control.dll+0x11130`). +/// +/// Unlike [`update_loaded_mission_control_node_stage`], this also propagates +/// depletion and stage flags through descendants. The whole provider table is +/// committed only if every node can be updated; this CPU projection does not +/// run unsupported visibility, FX, or host callbacks. +pub fn propagate_loaded_mission_control_node_stage( + engine: &mut Engine, + handle: ObjectHandle, + key: u32, + update_effects: bool, +) -> Result<(), ControlResourceAccessError> { + transform_state(&engine.world, handle).map_err(ControlResourceAccessError::World)?; + let loaded = engine + .loaded + .as_mut() + .ok_or(ControlResourceAccessError::MissionUnavailable)?; + let table = loaded + .control_device_tables + .iter_mut() + .find(|table| table.handle == handle) + .ok_or(ControlResourceAccessError::ObjectUnavailable { handle })?; + propagate_control_node_stage(table, key, update_effects) +} + fn update_control_node_stage( table: &mut MissionControlDeviceTableState, key: u32, @@ -2346,6 +2372,120 @@ fn update_control_node_stage( Ok(changed) } +fn propagate_control_node_stage( + table: &mut MissionControlDeviceTableState, + key: u32, + update_effects: bool, +) -> Result<(), ControlResourceAccessError> { + let mut updated = table.clone(); + propagate_control_node_stage_inner(&mut updated, key, update_effects)?; + *table = updated; + Ok(()) +} + +fn propagate_control_node_stage_inner( + table: &mut MissionControlDeviceTableState, + key: u32, + update_effects: bool, +) -> Result<(), ControlResourceAccessError> { + let (index, row_count, previous_stage) = match &table.node_provider_status { + MissionControlNodeProviderTableStatus::Available(rows) => { + let row_count = rows.len(); + let index = usize::try_from(key) + .ok() + .filter(|index| *index < row_count) + .ok_or(ControlResourceAccessError::NodeProviderKeyOutOfRange { key, row_count })?; + (index, row_count, rows[index].current_stage) + } + MissionControlNodeProviderTableStatus::Unavailable(reason) => { + return Err(ControlResourceAccessError::NodeProviderTableUnavailable( + reason.clone(), + )) + } + }; + + update_control_node_stage(table, key, update_effects)?; + + let (stage, flags) = match &table.node_provider_status { + MissionControlNodeProviderTableStatus::Available(rows) => { + let row = &rows[index]; + let flags = row + .current_flags + .ok_or(ControlResourceAccessError::UnsupportedField { + factory_tag: 0, + field: "provider flags", + })?; + (row.current_stage, flags) + } + MissionControlNodeProviderTableStatus::Unavailable(reason) => { + return Err(ControlResourceAccessError::NodeProviderTableUnavailable( + reason.clone(), + )) + } + }; + let depleted_or_worsened = previous_stage < stage || flags & 0x10 != 0; + + for child_index in index + 1..row_count { + let (metadata, child_flags) = match &table.node_provider_status { + MissionControlNodeProviderTableStatus::Available(rows) => { + let row = &rows[child_index]; + let metadata = + row.initial_mesh_state + .ok_or(ControlResourceAccessError::UnsupportedField { + factory_tag: 0, + field: "provider mesh metadata", + })?; + let child_flags = row.current_flags; + (metadata, child_flags) + } + MissionControlNodeProviderTableStatus::Unavailable(reason) => { + return Err(ControlResourceAccessError::NodeProviderTableUnavailable( + reason.clone(), + )) + } + }; + if i64::from(metadata.parent_index) != i64::from(key) { + continue; + } + + let child_key = u32::try_from(child_index) + .map_err(|_| ControlResourceAccessError::InvalidNodeStageState { key })?; + let child_flags = child_flags.ok_or(ControlResourceAccessError::UnsupportedField { + factory_tag: 0, + field: "provider flags", + })?; + if child_flags & 0x140 == 0 { + let child_flags = if flags & 0xc0 == 0 { + (child_flags | if depleted_or_worsened { 0x100 } else { 0 }) & !0x80 + } else { + child_flags | 0x80 + }; + if let MissionControlNodeProviderTableStatus::Available(rows) = + &mut table.node_provider_status + { + rows[child_index].current_flags = Some(child_flags); + } + } + + if depleted_or_worsened { + let child_amount = match &table.node_provider_status { + MissionControlNodeProviderTableStatus::Available(rows) => { + f32::from_bits(rows[child_index].current_amount_bits) + } + MissionControlNodeProviderTableStatus::Unavailable(reason) => { + return Err(ControlResourceAccessError::NodeProviderTableUnavailable( + reason.clone(), + )) + } + }; + apply_control_node_delta(table, child_key, -child_amount)?; + } + + propagate_control_node_stage_inner(table, child_key, update_effects)?; + } + Ok(()) +} + fn control_stage_product(count: u32, ratio: f32) -> f32 { if ratio == 0.0 { return 0.0; @@ -5278,6 +5418,10 @@ mod tests { update_loaded_mission_control_node_stage(&mut engine, handle, 1, false), Err(ControlResourceAccessError::MissionUnavailable) ); + assert_eq!( + propagate_loaded_mission_control_node_stage(&mut engine, handle, 1, false), + Err(ControlResourceAccessError::MissionUnavailable) + ); let stale = ObjectHandle { generation: handle.generation.wrapping_add(1), @@ -5321,6 +5465,12 @@ mod tests { fparkan_world::WorldError::StaleHandle )) ); + assert_eq!( + propagate_loaded_mission_control_node_stage(&mut engine, stale, 1, false), + Err(ControlResourceAccessError::World( + fparkan_world::WorldError::StaleHandle + )) + ); fparkan_world::request_delete(&mut engine.world, handle).expect("delete object"); assert_eq!( @@ -5329,6 +5479,12 @@ mod tests { fparkan_world::WorldError::StaleHandle )) ); + assert_eq!( + propagate_loaded_mission_control_node_stage(&mut engine, handle, 1, false), + Err(ControlResourceAccessError::World( + fparkan_world::WorldError::StaleHandle + )) + ); assert_eq!( apply_loaded_mission_control_node_delta(&mut engine, handle, 1, -1.0), @@ -5696,6 +5852,132 @@ mod tests { ); } + #[test] + fn control_node_stage_descendants_follow_native_walk_and_rollback() { + let make_rows = |parents: [i32; 5]| { + parents + .into_iter() + .enumerate() + .map(|(index, parent_index)| { + let flags = if index == 0 { 0xd2 } else { 2 }; + let mut row = synthetic_node_provider_row(0, index, 1.0_f32.to_bits()); + row.current_flags = Some(flags); + row.initial_mesh_state = Some(MissionControlNodeInitialMeshState { + parent_index, + flags, + stage_count: 2, + }); + row.mesh_state = Some(MissionControlNodeMeshState { + type1_value_bits: 1.0_f32.to_bits(), + visible_stage: 0, + }); + row + }) + .collect::>() + }; + + let mut rows = make_rows([-1, 0, 0, 1, 3]); + rows[1].current_flags = Some(0x140); + rows[1].initial_mesh_state.as_mut().unwrap().flags = 0x140; + let mut table = synthetic_control_device_table_with_providers( + MissionControlDeviceTableStatus::Available(Vec::new()), + MissionControlNodeProviderTableStatus::Available(rows), + ); + propagate_control_node_stage(&mut table, 0, false).unwrap(); + let MissionControlNodeProviderTableStatus::Available(rows) = &table.node_provider_status + else { + panic!() + }; + assert_eq!(rows[0].current_flags, Some(0xd2)); + for index in [1, 3, 4, 2] { + assert_eq!(rows[index].current_amount_bits, 0.0_f32.to_bits()); + assert_eq!( + rows[index].current_flags, + Some(if index == 1 { 0x150 } else { 0x92 }) + ); + assert_eq!(rows[index].processed_amount_bits, 100.0_f32.to_bits()); + } + assert_eq!(table.node_provider_amount_bits, Some(100.0_f32.to_bits())); + + let mut rows = make_rows([-1, 0, 0, 1, 3]); + rows[0].current_flags = Some(2); + rows[0].current_type1_value_bits = 0.0_f32.to_bits(); + rows[1].current_flags = Some(0x82); + rows[1].initial_mesh_state.as_mut().unwrap().flags = 0x82; + let mut table = synthetic_control_device_table_with_providers( + MissionControlDeviceTableStatus::Available(Vec::new()), + MissionControlNodeProviderTableStatus::Available(rows), + ); + propagate_control_node_stage(&mut table, 0, false).unwrap(); + let MissionControlNodeProviderTableStatus::Available(rows) = &table.node_provider_status + else { + panic!() + }; + assert_eq!(rows[0].current_stage, 2); + assert_eq!(rows[0].current_flags, Some(0x202)); + for index in [1, 3, 4, 2] { + assert_eq!(rows[index].current_amount_bits, 0.0_f32.to_bits()); + assert_eq!(rows[index].current_stage, 2); + assert_eq!(rows[index].current_flags, Some(0x312)); + } + + let mut rows = make_rows([-1, 0, 0, 1, 3]); + rows[0].current_flags = Some(2); + rows[0].current_stage = 1; + rows[0].current_type1_value_bits = 0.5_f32.to_bits(); + rows[0].initial_mesh_state.as_mut().unwrap().stage_count = 2; + rows[1].current_flags = Some(0x8a); + rows[1].current_stage = 0; + rows[1].current_type1_value_bits = 0.5_f32.to_bits(); + rows[1].initial_mesh_state.as_mut().unwrap().flags = 0x8a; + rows[1].initial_mesh_state.as_mut().unwrap().stage_count = 2; + rows[1].mesh_state.as_mut().unwrap().type1_value_bits = 0.5_f32.to_bits(); + let mut table = synthetic_control_device_table_with_providers( + MissionControlDeviceTableStatus::Available(Vec::new()), + MissionControlNodeProviderTableStatus::Available(rows), + ); + let before = table.clone(); + assert_eq!( + propagate_control_node_stage(&mut table, 0, true), + Err(ControlResourceAccessError::UnsupportedNodeEffects { key: 1 }) + ); + assert_eq!(table, before); + propagate_control_node_stage(&mut table, 0, false).unwrap(); + let MissionControlNodeProviderTableStatus::Available(rows) = &table.node_provider_status + else { + panic!() + }; + assert_eq!(rows[1].current_flags, Some(0x20a)); + assert_eq!(rows[1].current_stage, 1); + assert_eq!(rows[1].current_amount_bits, 100.0_f32.to_bits()); + let before = table.clone(); + assert_eq!( + propagate_control_node_stage(&mut table, u32::MAX, false), + Err(ControlResourceAccessError::NodeProviderKeyOutOfRange { + key: u32::MAX, + row_count: 5, + }) + ); + assert_eq!(table, before); + + let mut rows = make_rows([-1, 0, 0, 1, 3]); + rows[2].mesh_state = None; + rows[4].current_flags = None; + let mut table = synthetic_control_device_table_with_providers( + MissionControlDeviceTableStatus::Available(Vec::new()), + MissionControlNodeProviderTableStatus::Available(rows), + ); + let before = table.clone(); + assert_eq!( + propagate_control_node_stage(&mut table, 0, false), + Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 0, + field: "provider flags", + }) + ); + assert_eq!(table, before); + } + #[test] fn common_factory_into_overrides_preserve_native_constructor_defaults() { const INTO: u32 = u32::from_le_bytes(*b"INTO"); @@ -6848,11 +7130,38 @@ mod tests { 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); - let tables = loaded_mission_control_device_tables(&engine).expect("Control tables"); + let tables = loaded_mission_control_device_tables(&engine) + .expect("Control tables") + .to_vec(); 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; + let node_provider_count = match &tables[0].node_provider_status { + MissionControlNodeProviderTableStatus::Available(rows) => rows.len(), + MissionControlNodeProviderTableStatus::Unavailable(reason) => { + panic!("licensed Control provider rows unavailable: {reason:?}") + } + }; + let before_invalid_node_key = loaded_mission_control_device_tables(&engine) + .unwrap() + .to_vec(); + assert_eq!( + propagate_loaded_mission_control_node_stage( + &mut engine, + control_handle, + u32::MAX, + false, + ), + Err(ControlResourceAccessError::NodeProviderKeyOutOfRange { + key: u32::MAX, + row_count: node_provider_count, + }) + ); + assert_eq!( + loaded_mission_control_device_tables(&engine), + Some(before_invalid_node_key.as_slice()) + ); assert_eq!( query_loaded_mission_control_resource(&engine, control_handle, 1) .expect("licensed selector 1") @@ -7219,13 +7528,13 @@ mod tests { ) .unwrap() .unwrap(); - assert!(update_loaded_mission_control_node_stage( + propagate_loaded_mission_control_node_stage( &mut mine_engine, mine_profile.handle, stage_key, - false + false, ) - .unwrap()); + .expect("licensed Control descendant stage propagation"); let MissionControlNodeProviderTableStatus::Available(rows) = &loaded_mission_control_device_tables(&mine_engine).unwrap()[0].node_provider_status else { 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 e7756c1..7b82ad3 100644 --- a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs +++ b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs @@ -1131,7 +1131,7 @@ internal static class BehaviorDistributorTickOracle { new float[] { 25.0f, 10.0f, 5.0f, 12.0f, 7.0f }, new byte[] { 0x02, 0x02, 0x02, 0x02, 0x02 }); const int rowCount = 5; - int[] parents = { -1, 0, 1, 0, 0 }; + int[] parents = { -1, 0, 0, 1, 3 }; uint[] stages = { 4, 2, 3, 2, 2 }; uint[] flags = { 0x02, 0x42, 0x02, 0x102, 0x02 }; for (int index = 0; index < rowCount; index++) { @@ -1169,14 +1169,14 @@ internal static class BehaviorDistributorTickOracle { IntPtr parent = table.Rows; IntPtr protected40 = IntPtr.Add(table.Rows, 0x2c); - IntPtr grandchild = IntPtr.Add(table.Rows, 2 * 0x2c); + IntPtr sibling = IntPtr.Add(table.Rows, 2 * 0x2c); IntPtr protected100 = IntPtr.Add(table.Rows, 3 * 0x2c); - IntPtr sibling = IntPtr.Add(table.Rows, 4 * 0x2c); + IntPtr grandchild = IntPtr.Add(table.Rows, 4 * 0x2c); IntPtr parentMeshRow = meshRows; IntPtr bit40MeshRow = IntPtr.Add(meshRows, 0x130); - IntPtr grandchildMeshRow = IntPtr.Add(meshRows, 2 * 0x130); + IntPtr siblingMeshRow = IntPtr.Add(meshRows, 2 * 0x130); IntPtr bit100MeshRow = IntPtr.Add(meshRows, 3 * 0x130); - IntPtr siblingMeshRow = IntPtr.Add(meshRows, 4 * 0x130); + IntPtr grandchildMeshRow = IntPtr.Add(meshRows, 4 * 0x130); Expect(ReadFloat(parent, 0x08) == 25.0f && ReadFloat(parent, 0x0c) == 25.0f && unchecked((uint)Marshal.ReadInt32(parent, 0x18)) == 3, "Control 0x11130 updates the parent stage and processed amount first"); @@ -1184,40 +1184,41 @@ internal static class BehaviorDistributorTickOracle { unchecked((uint)Marshal.ReadInt32(protected40, 0x18)) == 0 && (unchecked((uint)Marshal.ReadInt32(protected40, 0x28)) & 0x50) == 0x50, "Protected bit 0x40 does not stop child depletion, while its depleted-stage early return remains visible"); - Expect(ReadFloat(grandchild, 0x08) == 0.0f && ReadFloat(grandchild, 0x0c) == 125.0f && - unchecked((uint)Marshal.ReadInt32(grandchild, 0x18)) == 0 && - (unchecked((uint)Marshal.ReadInt32(grandchild, 0x28)) & 0x90) == 0x90, - "Control 0x11130 drains a protected child's descendant but its inherited 0x80 flag preserves the depleted stage snapshot"); Expect(ReadFloat(protected100, 0x08) == 0.0f && ReadFloat(protected100, 0x0c) == 0.0f && - unchecked((uint)Marshal.ReadInt32(protected100, 0x18)) == 2, - "Protected bit 0x100 does not stop child depletion or its later stage update"); + unchecked((uint)Marshal.ReadInt32(protected100, 0x18)) == 2 && + (unchecked((uint)Marshal.ReadInt32(protected100, 0x28)) & 0x100) != 0, + "Protected bit 0x100 skips flag rewriting but does not stop drain or descendant traversal"); + Expect(ReadFloat(grandchild, 0x08) == 0.0f && ReadFloat(grandchild, 0x0c) == 0.0f && + unchecked((uint)Marshal.ReadInt32(grandchild, 0x18)) == 2 && + (unchecked((uint)Marshal.ReadInt32(grandchild, 0x28)) & 0x100) != 0, + "The protected row's grandchild is visited before the root's next sibling"); Expect(ReadFloat(sibling, 0x08) == 0.0f && ReadFloat(sibling, 0x0c) == 0.0f && - unchecked((uint)Marshal.ReadInt32(sibling, 0x18)) == 2 && + unchecked((uint)Marshal.ReadInt32(sibling, 0x18)) == 3 && (unchecked((uint)Marshal.ReadInt32(sibling, 0x28)) & 0x100) != 0, "An unprotected sibling retains the native damaged flag while updating its stage"); Expect(unchecked((uint)Marshal.ReadInt32(parentMeshRow, 0x1c)) == 3 && unchecked((uint)Marshal.ReadInt32(bit40MeshRow, 0x1c)) == unchecked((uint)-99) && - unchecked((uint)Marshal.ReadInt32(grandchildMeshRow, 0x1c)) == unchecked((uint)-99) && + unchecked((uint)Marshal.ReadInt32(siblingMeshRow, 0x1c)) == 2 && unchecked((uint)Marshal.ReadInt32(bit100MeshRow, 0x1c)) == 1 && - unchecked((uint)Marshal.ReadInt32(siblingMeshRow, 0x1c)) == 1, + unchecked((uint)Marshal.ReadInt32(grandchildMeshRow, 0x1c)) == 1, "ANI slot 0x44 reaches native AniMesh RVA 0x5810 for changed visible stages only"); Expect(controlNodeUpdateCount == 4 && - String.Join(",", controlNodeUpdateIndices.ConvertAll(index => index.ToString()).ToArray()) == "1,2,3,4" && - String.Join(",", controlNodeUpdateFlags.ConvertAll(value => value.ToString("x")).ToArray()) == "42,82,102,102" && + String.Join(",", controlNodeUpdateIndices.ConvertAll(index => index.ToString()).ToArray()) == "1,3,4,2" && + String.Join(",", controlNodeUpdateFlags.ConvertAll(value => value.ToString("x")).ToArray()) == "42,102,102,102" && !controlNodeUpdateOwnerMismatch, "Control 0x11130 traverses descendants depth-first, checking callback owners and protected flags"); - Expect(String.Join(",", aniMeshStageTransitions.ToArray()) == "0:3,3:1,4:1" && + Expect(String.Join(",", aniMeshStageTransitions.ToArray()) == "0:3,3:1,4:1,2:2" && aniMeshNodeLifecycleCallbacks.Count == 0 && !aniMeshCallbackOwnerMismatch && !nativeAniMeshStageCallbackMissing, "Control descendant update preserves the native callback order and stage bounds"); Expect(String.Join(",", controlProviderCallbackTrace.ToArray()) == - "stage:0:3,amount:1,amount:2,amount:3,stage:3:1,amount:4,stage:4:1", + "stage:0:3,amount:1,amount:3,stage:3:1,amount:4,stage:4:1,amount:2,stage:2:2", "Control 0x11130 keeps amount, lifecycle, and visual-stage callbacks in native depth-first order"); Expect(ReadFloat(table.Owner, 0x590) == 25.0f, "Control 0x10f30 keeps the aggregate amount in sync after recursive child depletion"); Console.WriteLine("native-control-parent-propagation: controlHash=" + ExpectedControlSha256 + - " rva=0x11130 childOrder=[1,2,3,4] protected40/100=depleted bit40-stage-return=preserved" + + " rva=0x11130 childOrder=[1,3,4,2] protected40/100=depleted bit40-stage-return=preserved" + " callbacks=" + String.Join(",", controlProviderCallbackTrace.ToArray())); activeControlProviderRows = IntPtr.Zero; activeAniMeshInterface = IntPtr.Zero; diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index ee10d40..e8bdb42 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -351,11 +351,24 @@ MSH-часть проверки подтверждает начальный сн Control. Обработанное количество при ней остаётся прежним. Отдельный `update_loaded_mission_control_node_stage` реализует наследование parent ratio и stage, ceiling-арифметику, CPU visible stage и копию processed amount. -Обход потомков `0x11130` пока не перенесён. Нужные lifecycle/visibility/FX -callbacks дают явные ошибки до записи; `update_effects = true` поддержан -только без фактической смены стадии. Licensed двухстадийный узел шахты -подтверждает отдельный переход `0 → 1` при доле `0.5`, без FX и window path. Гибель root/critical узла и неизвестные flags/aggregate дают явную -ошибку до записи; неподдержанные численные диапазоны тоже отклоняются. +Отдельная `propagate_loaded_mission_control_node_stage` переносит `0x11130`: +после обновления строки она сканирует более поздние provider-строки и обходит +найденные поддеревья в native depth-first порядке. Для родителей +`[-1, 0, 0, 1, 3]` порядок — `1, 3, 4, 2`. Переносит унаследованный Control +ratio/stage, флаги `0x80/0x100` и drain количества; маска `0x140` пропускает +только перезапись flags, не drain и рекурсию. Ухудшение определяется ростом +стадии либо флагом истощения `0x10` уже обновлённого родителя. Отдельный +`update_loaded_mission_control_node_stage` сохраняет одиночную семантику +`0x11220`. + +Пропагация клонирует Control provider-таблицу и публикует её только после +успешного обхода. Неизвестная parent-связь в просматриваемом суффиксе, +lifecycle/visibility/FX callback или неподдержанное численное значение дают +ошибку без частичных изменений. `update_effects = true` отклоняется, если +стадия любого обработанного узла меняется и требуются неисполненные FX/host +callbacks. Licensed двухстадийный узел шахты подтверждает переход `0 → 1` через +новый API при доле `0.5`, без FX и window path. Гибель root/critical узла и +неизвестные flags/aggregate также остаются явными ошибками. Rust query обращается к загруженному объекту и поддерживает `0xe00` только для текущего descriptor tag `26` с factory tag `26`; другие активные typed tags и @@ -364,7 +377,8 @@ Licensed `min_l_n1.dat` подтверждает изменение `0xe00` и B `1.0` до `0.5` после явного уменьшения количества вдвое; это ещё не автоматический бюджетный цикл. Native oracle проверил изолированные пути распределения, обновления класса `4`, -стадийную арифметику `0x11220`, обход дочерних provider-строк `0x11130` и +стадийную арифметику `0x11220`, обход дочерних provider-строк `0x11130` на +interleaved parents `[-1, 0, 0, 1, 3]` с callback-порядком `1, 3, 4, 2` и visibility transition `0x11920` с исходными AniMesh callbacks. Отдельный бюджетный вектор исполняет `0x2d340` → `0x2dca0` → tag 15 `0x22bb0` → `0x10ba0`: подтверждены коэффициенты `0.5` и `1`, точные amount/ratio и diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index bd363e5..60d15e1 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -695,8 +695,8 @@ provider остаётся с заданным тестом значением `0 текущего Control stage/sentinel. Неизвестный узел даёт ошибку без частичной записи; callback отражён в CPU-проекции, но GPU/window path ещё не подключён. Delta API не вызывает стадийный проход. Отдельный single-node API для -`0x11220` описан в разделе о стадиях; обход `0x11130`, видимость и FX остаются -вне этого исполнения. +`0x11220` и отдельный API обхода потомков `0x11130` описаны в разделе о +стадиях; видимость и FX callbacks остаются вне этого исполнения. Истощение корня или критического узла возвращает `UnsupportedNodeLifecycle` до записи: состояние владельца `+0x550`, отложенная гибель и сопутствующие callbacks ещё не реализованы. Отсутствие MSH flags, provider aggregate, @@ -964,15 +964,33 @@ Native oracle и Rust-тест также проверяют двухстади стадии и отсутствие такого обновления при защищённом раннем выходе. Licensed тест повторяет отдельный переход `0 → 1` на двухстадийном узле настоящего `min_l_n1.dat`, с `update_effects = false`. Полный игровой вызов не подменяется -этим режимом: FX, lifecycle, видимость и обход потомков ещё требуют реализации. +этим режимом: FX, lifecycle и видимость всё ещё требуют реализации; обход +потомков доступен отдельным CPU API и пока не подключён к игровому циклу. -После перехода родительского ряда `FUN_10011130` находит более поздние строки -с его индексом в поле parent. Обход идёт в глубину: сначала ребёнок и всё его -поддерево, затем следующий ребёнок. Рост стадии или истощение родителя снимает -количество с дочерних строк; защитные флаги `0x40` и `0x100` меняют часть -обработки флагов, но не останавливают снятие количества и обход потомков в -проверенных входах. Нативный тест задал родителя с дочерними рядами, снял их -количество и проверил порядок вызовов и значение stage snapshot. +`FUN_10011130` сохраняет старую стадию, вызывает `0x11220`, затем сохраняет +обновлённые flags родителя для всего прохода. В исследованной сборке +`0x11130` вызывает `0x11220` по RVA `0x11156`; caller-ы `0x11130` находятся в +`0x10ba0`, `0x12a40`, `0x12ce0`, `0x13180` и в самой рекурсивной функции. +`0x10f30` вызывает `0x11220` напрямую по RVA `0x1110f`. + +Затем `0x11130` сканирует provider-строки с большим индексом и выбирает строки, +у которых parent равен текущему ключу. Обход идёт в глубину: сначала ребёнок и +всё его поддерево, затем следующий ребёнок. Для parents `[-1, 0, 0, 1, 3]` +порядок callbacks равен `1, 3, 4, 2`. Если стадия родителя выросла или у него +стоит флаг истощения `0x10`, Control снимает количество с ребёнка. При флагах +родителя `0xc0` дочернему ряду ставится `0x80`; в обычной ветви ставится `0x100` +при ухудшении и очищается `0x80`. Дочерний mask `0x140` запрещает только +перезапись его flags: количество всё равно снимается, и обход продолжается. +Стадии детей пересчитываются, даже если родитель не ухудшился. + +Rust API `propagate_loaded_mission_control_node_stage` выполняет этот обход +отдельно от single-node `update_loaded_mission_control_node_stage`. Он работает +с копией provider-таблицы и публикует её только после успешной обработки всех +потомков. Если в проверяемом суффиксе отсутствуют mesh metadata и поэтому нельзя +установить parent-связи, либо потомку требуются lifecycle, FX или visibility +callbacks, API возвращает ошибку и оставляет исходную таблицу без изменений. +Native oracle использует синтетические NDP/Node38-строки и исходные +Control/AniMesh callbacks; это не полное исполнение или сравнение миссии. Переход между обычной стадией и sentinel `stage == count` вызывает `Control.dll!FUN_10011920`, только если у строки не установлен флаг `0x02`. diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index 9dddc71..777caf1 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -997,22 +997,43 @@ clock Control. даёт `[14, 28, 10]`. При вычитании последовательный способ начинает с последней строки. Так одна таблица описывает и текущее значение, и то, как оно меняется. -У строк есть и связи с родительскими узлами. Когда стадия родителя меняется, -Control пересчитывает дочерние строки в глубину: ребёнка вместе со всем его -поддеревом, затем следующего ребёнка. Для числа вариантов `count` внутренняя -стадия вычисляется как `count - ceil(f32(count * ratio))`, а видимое значение -ограничивается диапазоном модели. Например, при трёх стадиях доли +У строк есть и связи с родительскими узлами. Нативный `Control.dll` сначала +обновляет запрошенную строку через `0x11220`, затем `0x11130` ищет её детей +среди строк с большим индексом. Каждый найденный ребёнок обновляется вместе со +своим поддеревом, и только потом обход продолжается со следующим соседом. +Например, для родителей `[-1, 0, 0, 1, 3]` порядок строк становится +`1 → 3 → 4 → 2`: сначала весь первый участок дерева, затем его сосед. + +Стадия увеличивается, когда доля ресурса уменьшается. Для числа вариантов +`count` внутренняя стадия вычисляется как `count - ceil(f32(count * ratio))`, +а видимое значение ограничивается диапазоном модели. Например, при трёх стадиях доли `[0.1, 0.4, 0.5, 1, 0]` дали `[2, 1, 1, 0, 3]`; последнее значение — sentinel истощения, а в модель передаётся индекс не выше `count - 1`. Нативные проверки -подтвердили порядок обхода и передачу состояния в AniMesh. +подтвердили арифметику и порядок обхода. + +После обновления родителя Control сравнивает его старую стадию с новой и +проверяет флаг истощения `0x10`. Если стадия выросла либо строка истощена, +количество каждого ребёнка полностью уменьшается тем же путём, что и обычная +добавка ресурса. Для ребёнка без защитной маски родительские флаги `0xc0` +задают флаг `0x80`; в обычной ветви переход ставит `0x100` при ухудшении и +очищает у ребёнка `0x80`. Маска `0x140` запрещает только изменение этих флагов: +уменьшение количества и рекурсивный вызов всё равно выполняются. Дочерние +стадии пересчитываются и тогда, когда родитель не ухудшился. Выбранная стадия также задаёт, какие данные MSH попадают в пересчёт видимости. У одного синтетического узла первый слот давал `[42, 330]`, а слот другой стадии — `[102, 570]`. В проверенных векторах одинаковые данные при скрытии и повторном включении восстановили суммы; переходы с разными слотами — нет. Эти проверки исполняют исходные DLL на тестовых строках, но не являются запуском Control на -полной миссии. Rust выполняет отдельный стадийный шаг в подтверждённой области, -но обход потомков, пересчёт видимости и остальные callbacks ещё не подключены. +полной миссии. + +В Rust `update_loaded_mission_control_node_stage` сохраняет отдельный шаг одной +строки, а `propagate_loaded_mission_control_node_stage` обновляет строку и её +поддерево в том же порядке, что native `0x11130`. Весь снимок provider-таблицы +публикуется только после успешной обработки всех потомков. Если позже по обходу +понадобится неизвестная связь узлов, lifecycle, FX или пересчёт видимости, +операция сообщает об этом и оставляет снимок без изменений. Эта CPU-проекция +ещё не подключена к игровому Control clock или выводу модели в окно. Прямой путь изменения количества остаётся отдельной операцией. Подробные границы приведены в [техническом приложении](../appendices/script-vm.md#родители-стадии-и-видимость-provider-узлов).