diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 8e8224b..7ba114d 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -263,6 +263,8 @@ pub struct MissionControlNodeProviderRow { pub current_type1_value_bits: u32, /// Current native `+0x28` flags, when the initial mesh metadata is known. pub current_flags: Option, + /// Native Control stage at `+0x18`; `stage_count` is the exhaustion sentinel. + pub current_stage: u32, /// MSH state exposed at the same global ordinal, if the mesh projection exists. pub initial_mesh_state: Option, /// Current model-node fields, when its global ordinal was resolved. @@ -341,6 +343,18 @@ pub enum ControlResourceAccessError { InvalidNodeProviderAmount { key: u32 }, /// Depleting a root or critical node needs the unimplemented object lifecycle. UnsupportedNodeLifecycle { key: u32 }, + /// A provider row references a parent outside the loaded table. + NodeProviderParentOutOfRange { + key: u32, + parent_index: i32, + row_count: usize, + }, + /// The stage count or ratio is outside the confirmed stage domain. + InvalidNodeStageState { key: u32 }, + /// A stage transition needs the unimplemented geometry/visibility update. + UnsupportedNodeVisibility { key: u32 }, + /// A stage transition requests unimplemented FX and host notifications. + UnsupportedNodeEffects { 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. @@ -2196,6 +2210,154 @@ pub fn apply_loaded_mission_control_node_delta( apply_control_node_delta(table, key, delta) } +/// Applies the confirmed single-node stage path of native Control `0x11220`. +/// +/// Copies parent ratio/stage when requested, updates the processed amount and +/// CPU visible stage, and returns whether the Control stage changed. It does not +/// traverse descendants (`0x11130`) or run the separate Control clock. +/// `update_effects` is the original native argument, not a compatibility fallback. +/// +/// # Errors +/// Rejects unavailable state, invalid parent/count/ratio, and transitions needing +/// unsupported lifecycle, visibility, FX, or host callbacks. No partial state is +/// published on error. Depleted protected nodes retain the native early return. +pub fn update_loaded_mission_control_node_stage( + engine: &mut Engine, + handle: ObjectHandle, + key: u32, + update_effects: bool, +) -> Result { + 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 })?; + update_control_node_stage(table, key, update_effects) +} + +fn update_control_node_stage( + table: &mut MissionControlDeviceTableState, + key: u32, + update_effects: bool, +) -> Result { + 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 index = usize::try_from(key) + .ok() + .filter(|index| *index < row_count) + .ok_or(ControlResourceAccessError::NodeProviderKeyOutOfRange { key, row_count })?; + let row = &rows[index]; + let flags = row + .current_flags + .ok_or(ControlResourceAccessError::UnsupportedField { + factory_tag: 0, + field: "provider flags", + })?; + if flags & 0x1c0 != 0 { + if flags & 0x10 == 0 { + return Err(ControlResourceAccessError::UnsupportedNodeLifecycle { key }); + } + if flags & 0xc0 != 0 { + return Ok(false); + } + } + let metadata = row + .initial_mesh_state + .ok_or(ControlResourceAccessError::UnsupportedField { + factory_tag: 0, + field: "provider mesh metadata", + })?; + let count = metadata.stage_count; + if count == 0 { + return Err(ControlResourceAccessError::InvalidNodeStageState { key }); + } + let parent = if flags & 0x0c != 0 { + Some( + usize::try_from(metadata.parent_index) + .ok() + .and_then(|parent| rows.get(parent)) + .ok_or(ControlResourceAccessError::NodeProviderParentOutOfRange { + key, + parent_index: metadata.parent_index, + row_count, + })?, + ) + } else { + None + }; + let ratio_bits = if flags & 4 != 0 { + parent + .expect("parent validated for inheritance") + .current_type1_value_bits + } else { + row.current_type1_value_bits + }; + let ratio = checked_control_float(0, "provider stage ratio", f32::from_bits(ratio_bits))?; + if !(0.0..=1.0).contains(&ratio) { + return Err(ControlResourceAccessError::InvalidNodeStageState { key }); + } + let stage = if flags & 8 != 0 { + parent + .expect("parent validated for inheritance") + .current_stage + .min(count) + } else { + let product = control_stage_product(count, ratio); + count.wrapping_sub(product.ceil() as u64 as u32).min(count) + }; + let changed = stage != row.current_stage; + if changed { + if update_effects { + return Err(ControlResourceAccessError::UnsupportedNodeEffects { key }); + } + if flags & 2 == 0 && (stage == count || row.current_stage == count) { + return Err(ControlResourceAccessError::UnsupportedNodeVisibility { key }); + } + if row.mesh_state.is_none() { + return Err(ControlResourceAccessError::UnsupportedField { + factory_tag: 0, + field: "model node state", + }); + } + } + let row = &mut rows[index]; + row.current_type1_value_bits = ratio_bits; + row.processed_amount_bits = row.current_amount_bits; + if changed { + row.current_flags = Some(flags | 0x200); + row.current_stage = stage; + row.mesh_state + .as_mut() + .expect("model state validated") + .visible_stage = stage.min(count - 1); + } + Ok(changed) +} + +fn control_stage_product(count: u32, ratio: f32) -> f32 { + if ratio == 0.0 { + return 0.0; + } + // FILD keeps the integer exact; round the mantissa product once, then rescale exactly. + let bits = ratio.to_bits(); + let significand = (bits & 0x007f_ffff) | 0x0080_0000; + let exponent = ((bits >> 23) & 0xff) as i32 - 150; + let product = u64::from(count) * u64::from(significand); + (f64::from(product as f32) * 2.0_f64.powi(exponent)) as f32 +} + fn initial_node_provider_amount(status: &MissionControlNodeProviderTableStatus) -> Option { let MissionControlNodeProviderTableStatus::Available(rows) = status else { return None; @@ -3773,6 +3935,7 @@ fn append_control_node_provider_rows( processed_amount_bits: source_row.max_volume.to_bits(), current_type1_value_bits: 0x3f80_0000, current_flags: None, + current_stage: 0, initial_mesh_state: None, mesh_state: None, }); @@ -4344,6 +4507,7 @@ mod tests { processed_amount_bits: 100.0_f32.to_bits(), current_type1_value_bits: initial_type1_value_bits, current_flags: None, + current_stage: 0, initial_mesh_state: None, mesh_state: None, } @@ -5110,6 +5274,11 @@ mod tests { Err(ControlResourceAccessError::MissionUnavailable) ); + assert_eq!( + update_loaded_mission_control_node_stage(&mut engine, handle, 1, false), + Err(ControlResourceAccessError::MissionUnavailable) + ); + let stale = ObjectHandle { generation: handle.generation.wrapping_add(1), slot: handle.slot, @@ -5146,7 +5315,21 @@ mod tests { )) ); + assert_eq!( + update_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!( + update_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), Err(ControlResourceAccessError::World( @@ -5348,6 +5531,171 @@ mod tests { assert_eq!(table.node_provider_amount_bits, total); } + #[test] + fn control_node_stage_matches_native_inheritance_and_visible_sentinel() { + let descriptors = [ + (-1, 0.25_f32, 4, 0x02), + (0, 0.875, 5, 0x06), + (0, 0.9, 2, 0x0a), + (1, 0.1, 6, 0x0e), + (-1, 0.1, 3, 0x02), + (-1, 0.4, 3, 0x02), + (-1, 0.5, 3, 0x02), + (-1, 1.0, 3, 0x102), + (-1, 0.0, 3, 0x02), + (-1, 0.5, 2, 0x02), + (-1, 0.75, 16_777_217, 0x02), + ]; + let rows = descriptors + .into_iter() + .enumerate() + .map(|(index, (parent_index, ratio, stage_count, flags))| { + let mut row = synthetic_node_provider_row(0, index, ratio.to_bits()); + row.current_amount_bits = (index as f32 + 1.0).to_bits(); + row.processed_amount_bits = (90.0 + index as f32).to_bits(); + row.current_flags = Some(flags); + row.current_stage = u32::from(index == 7); + row.initial_mesh_state = Some(MissionControlNodeInitialMeshState { + parent_index, + flags, + stage_count, + }); + row.mesh_state = Some(MissionControlNodeMeshState { + type1_value_bits: 0, + visible_stage: (-99_i32) as u32, + }); + row + }) + .collect(); + let mut table = synthetic_control_device_table_with_providers( + MissionControlDeviceTableStatus::Available(Vec::new()), + MissionControlNodeProviderTableStatus::Available(rows), + ); + for key in [0_u32, 1, 2, 3, 4, 5, 6, 8, 9, 10] { + assert!(update_control_node_stage(&mut table, key, false).unwrap()); + } + let MissionControlNodeProviderTableStatus::Available(rows) = &table.node_provider_status + else { + panic!() + }; + for (index, expected) in [3, 3, 2, 3, 2, 1, 1, 1, 3, 1, 4_194_304] + .into_iter() + .enumerate() + { + assert_eq!(rows[index].current_stage, expected); + if index == 7 { + continue; + } + assert_eq!( + rows[index].processed_amount_bits, + rows[index].current_amount_bits + ); + assert_eq!( + rows[index].mesh_state.unwrap().visible_stage, + expected.min(descriptors[index].2 - 1) + ); + assert_eq!(rows[index].mesh_state.unwrap().type1_value_bits, 0); + assert_ne!(rows[index].current_flags.unwrap() & 0x200, 0); + } + assert_eq!(rows[1].current_type1_value_bits, 0.25_f32.to_bits()); + assert_eq!(rows[3].current_type1_value_bits, 0.25_f32.to_bits()); + let before = table.clone(); + assert_eq!( + update_control_node_stage(&mut table, 7, false), + Err(ControlResourceAccessError::UnsupportedNodeLifecycle { key: 7 }) + ); + assert_eq!(table, before); + let MissionControlNodeProviderTableStatus::Available(rows) = + &mut table.node_provider_status + else { + panic!() + }; + rows[6].processed_amount_bits = 999.0_f32.to_bits(); + rows[6].mesh_state.as_mut().unwrap().visible_stage = 77; + assert!(!update_control_node_stage(&mut table, 6, true).unwrap()); + let MissionControlNodeProviderTableStatus::Available(rows) = &table.node_provider_status + else { + panic!() + }; + assert_eq!(rows[6].processed_amount_bits, rows[6].current_amount_bits); + assert_eq!(rows[6].mesh_state.unwrap().visible_stage, 77); + let MissionControlNodeProviderTableStatus::Available(rows) = + &mut table.node_provider_status + else { + panic!() + }; + rows[8].current_stage = 0; + let before = table.clone(); + assert_eq!( + update_control_node_stage(&mut table, 8, true), + Err(ControlResourceAccessError::UnsupportedNodeEffects { key: 8 }) + ); + assert_eq!(table, before); + let MissionControlNodeProviderTableStatus::Available(rows) = + &mut table.node_provider_status + else { + panic!() + }; + rows[8].current_flags = Some(0); + let before = table.clone(); + assert_eq!( + update_control_node_stage(&mut table, 8, false), + Err(ControlResourceAccessError::UnsupportedNodeVisibility { key: 8 }) + ); + assert_eq!(table, before); + let MissionControlNodeProviderTableStatus::Available(rows) = + &mut table.node_provider_status + else { + panic!() + }; + rows[1].initial_mesh_state.as_mut().unwrap().parent_index = -1; + let before = table.clone(); + assert_eq!( + update_control_node_stage(&mut table, 1, false), + Err(ControlResourceAccessError::NodeProviderParentOutOfRange { + key: 1, + parent_index: -1, + row_count: 11 + }) + ); + assert_eq!(table, before); + let MissionControlNodeProviderTableStatus::Available(rows) = + &mut table.node_provider_status + else { + panic!() + }; + rows[1].initial_mesh_state.as_mut().unwrap().parent_index = 0; + rows[1].current_stage = 0; + rows[1].current_type1_value_bits = 0.875_f32.to_bits(); + rows[1].mesh_state = None; + let before = table.clone(); + assert!(matches!( + update_control_node_stage(&mut table, 1, false), + Err(ControlResourceAccessError::UnsupportedField { + field: "model node state", + .. + }) + )); + assert_eq!( + table, before, + "failed inherited-stage update must not copy the parent ratio" + ); + let MissionControlNodeProviderTableStatus::Available(rows) = + &mut table.node_provider_status + else { + panic!() + }; + rows[6].current_flags = Some(0x52); + rows[6].initial_mesh_state = None; + rows[6].processed_amount_bits = 999.0_f32.to_bits(); + let before = table.clone(); + assert!(!update_control_node_stage(&mut table, 6, false).unwrap()); + assert_eq!( + table, before, + "depleted protected node returns before reading metadata or processing amount" + ); + } + #[test] fn common_factory_into_overrides_preserve_native_constructor_defaults() { const INTO: u32 = u32::from_le_bytes(*b"INTO"); @@ -6858,6 +7206,48 @@ mod tests { visible_stage: 0, }) ); + let stage_key = 1; + let stage_row = &mine_rows[stage_key as usize]; + assert_eq!(stage_row.initial_mesh_state.unwrap().stage_count, 2); + assert_eq!(stage_row.current_flags, Some(2)); + let stage_initial = stage_row.initial_amount_bits; + let _publication = apply_loaded_mission_control_node_delta( + &mut mine_engine, + mine_profile.handle, + stage_key, + -f32::from_bits(stage_initial) * 0.5, + ) + .unwrap() + .unwrap(); + assert!(update_loaded_mission_control_node_stage( + &mut mine_engine, + mine_profile.handle, + stage_key, + false + ) + .unwrap()); + let MissionControlNodeProviderTableStatus::Available(rows) = + &loaded_mission_control_device_tables(&mine_engine).unwrap()[0].node_provider_status + else { + panic!() + }; + assert_eq!(rows[stage_key as usize].current_stage, 1); + assert_eq!( + rows[stage_key as usize].mesh_state.unwrap().visible_stage, + 1 + ); + assert_eq!( + rows[stage_key as usize] + .mesh_state + .unwrap() + .type1_value_bits, + 0.5_f32.to_bits() + ); + assert_eq!( + rows[stage_key as usize].processed_amount_bits, + rows[stage_key as usize].current_amount_bits + ); + assert_eq!(rows[stage_key as usize].initial_amount_bits, stage_initial); } #[test] 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 61cbc01..e7756c1 100644 --- a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs +++ b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs @@ -1013,7 +1013,7 @@ internal static class BehaviorDistributorTickOracle { Marshal.GetDelegateForFunctionPointer(IntPtr.Add(aniMeshModule, 0x05500), typeof(NativeAniMeshNodeLifecycleCallback)); using (Fixture fixture = new Fixture()) { - const int rowCount = 9; + const int rowCount = 11; IntPtr owner = fixture.Allocate(0x664); IntPtr rows = fixture.Allocate(rowCount * 0x2c); IntPtr aniInterface = fixture.Allocate(0x1b0); @@ -1043,6 +1043,8 @@ internal static class BehaviorDistributorTickOracle { WriteProviderStageRow(rows, 6, -1, 4.0f, 94.0f, 0.5f, 0, 3, 0x02); WriteProviderStageRow(rows, 7, -1, 5.0f, 95.0f, 1.0f, 1, 3, 0x102); WriteProviderStageRow(rows, 8, -1, 6.0f, 96.0f, 0.0f, 0, 3, 0x02); + WriteProviderStageRow(rows, 9, -1, 10.0f, 90.0f, 0.5f, 0, 2, 0x02); + WriteProviderStageRow(rows, 10, -1, 11.0f, 90.0f, 0.75f, 0, 16777217, 0x02); aniMeshStageTransitions.Clear(); aniMeshNodeLifecycleCallbacks.Clear(); @@ -1057,10 +1059,10 @@ internal static class BehaviorDistributorTickOracle { updateStage(owner, 3, 0); for (int index = 4; index < rowCount; index++) updateStage(owner, index, 0); - uint[] expectedStages = { 3, 3, 2, 3, 2, 1, 1, 0, 3 }; - uint[] expectedVisibleStages = { 3, 3, 1, 3, 2, 1, 1, 0, 2 }; + uint[] expectedStages = { 3, 3, 2, 3, 2, 1, 1, 0, 3, 1, 4194304 }; + uint[] expectedVisibleStages = { 3, 3, 1, 3, 2, 1, 1, 0, 2, 1, 4194304 }; uint[] expectedAmounts = { 0x42200000, 0x41400000, 0x40e00000, 0x40400000, - 0x40000000, 0x40400000, 0x40800000, 0x40a00000, 0x40c00000 }; + 0x40000000, 0x40400000, 0x40800000, 0x40a00000, 0x40c00000, 0x41200000, 0x41300000 }; for (int index = 0; index < rowCount; index++) { IntPtr row = IntPtr.Add(rows, index * 0x2c); IntPtr meshRow = IntPtr.Add(meshRows, index * 0x130); @@ -1080,17 +1082,33 @@ internal static class BehaviorDistributorTickOracle { "Control flag 4 copies the parent type-1 ratio before stage calculation"); Expect(ReadFloat(meshRows, 0x130 + 0x124) == 0.0f && ReadFloat(rows, 0x2c + 0x14) == 0.25f, "inheriting a parent provider ratio does not rewrite the separate AniMesh node value"); - Expect(String.Join(",", aniMeshStageTransitions.ToArray()) == "0:3,1:3,2:1,3:3,4:2,5:1,6:1,7:0,8:2" && + Expect(String.Join(",", aniMeshStageTransitions.ToArray()) == "0:3,1:3,2:1,3:3,4:2,5:1,6:1,7:0,8:2,9:1,10:4194304" && String.Join(",", aniMeshNodeLifecycleCallbacks.ToArray()) == "7:101:4" && !aniMeshCallbackOwnerMismatch && !controlNodeUpdateOwnerMismatch && !nativeAniMeshStageCallbackMissing, "Control stage and slot-0x20 callbacks reach the original AniMesh RVA 0x5810 in direct update order"); Expect(String.Join(",", controlProviderCallbackTrace.ToArray()) == - "stage:0:3,stage:1:3,stage:2:1,stage:3:3,stage:4:2,stage:5:1,stage:6:1,lifecycle:7:101:4,stage:7:0,stage:8:2", + "stage:0:3,stage:1:3,stage:2:1,stage:3:3,stage:4:2,stage:5:1,stage:6:1,lifecycle:7:101:4,stage:7:0,stage:8:2,stage:9:1,stage:10:4194304", "AniMesh slot 0x20 receives its native node, selector, and mask arguments before stage publication"); + int transitionsBeforeNoOp = aniMeshStageTransitions.Count; + IntPtr unchanged = IntPtr.Add(rows, 6 * 0x2c); + Marshal.WriteInt32(unchanged, 0x0c, FloatBits(999.0f)); + Marshal.WriteInt32(meshRows, 6 * 0x130 + 0x1c, 77); + updateStage(owner, 6, 1); + Expect(ReadFloat(unchanged, 0x0c) == ReadFloat(unchanged, 8) && + Marshal.ReadInt32(meshRows, 6 * 0x130 + 0x1c) == 77 && + aniMeshStageTransitions.Count == transitionsBeforeNoOp, + "unchanged stage refreshes processed amount without stage or effects callbacks"); + Marshal.WriteInt32(unchanged, 0x28, 0x52); + Marshal.WriteInt32(unchanged, 0x0c, FloatBits(999.0f)); + updateStage(owner, 6, 0); + Expect(ReadFloat(unchanged, 0x0c) == 999.0f && + aniMeshStageTransitions.Count == transitionsBeforeNoOp, + "depleted protected node returns without processing its amount"); + Console.WriteLine("native-control-parent-stage-arithmetic: controlHash=" + ExpectedControlSha256 + " rva=0x11220 bit4=ratio bit8=parent-stage ceil=[0.1,0.4,0.5,1,0]=>[2,1,1,0,3]" + - " callbacks=[0:3,1:3,2:1,3:3,4:2,5:1,6:1,lifecycle7:101:4,7:0,8:2]"); + " callbacks=[0:3,1:3,2:1,3:3,4:2,5:1,6:1,lifecycle7:101:4,7:0,8:2,9:1,10:4194304]"); activeAniMeshInterface = IntPtr.Zero; activeNativeAniMeshStageTransition = null; activeNativeAniMeshNodeLifecycle = null; diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index 227e2a4..ee10d40 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -347,8 +347,14 @@ MSH-часть проверки подтверждает начальный сн соответствующего узла модели. Исходный `AniMesh.dll+0x5750` подтверждён в связке с `Control+0x10f30`: меняется только поле узла `+0x124`, а стадия и соседние узлы сохраняются. Эта проекция ещё не подключена к GPU/window path -и не выполняет пересчёт стадий, видимости, FX или время Control. Поэтому обработанное количество остаётся -прежним. Гибель root/critical узла и неизвестные flags/aggregate дают явную +и сама delta-операция не выполняет пересчёт стадий, видимости, FX или время +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 дают явную ошибку до записи; неподдержанные численные диапазоны тоже отклоняются. Rust query обращается к загруженному объекту и поддерживает `0xe00` только для diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 6f52556..bd363e5 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -692,9 +692,11 @@ provider остаётся с заданным тестом значением `0 стадийное наследование доли родителя тоже не переписывает `+0x124` модели. Поэтому Rust хранит `mesh_state.type1_value_bits` отдельно от `current_type1_value_bits` provider, а `mesh_state.visible_stage` — отдельно от -будущего Control stage/sentinel. Неизвестный узел даёт ошибку без частичной +текущего Control stage/sentinel. Неизвестный узел даёт ошибку без частичной записи; callback отражён в CPU-проекции, но GPU/window path ещё не подключён. -Полный пересчёт стадии `0x11130`/`0x11220`, видимости и FX также не вызывается. +Delta API не вызывает стадийный проход. Отдельный single-node API для +`0x11220` описан в разделе о стадиях; обход `0x11130`, видимость и FX остаются +вне этого исполнения. Истощение корня или критического узла возвращает `UnsupportedNodeLifecycle` до записи: состояние владельца `+0x550`, отложенная гибель и сопутствующие callbacks ещё не реализованы. Отсутствие MSH flags, provider aggregate, @@ -928,6 +930,42 @@ provider-состояния в реальной миссии. Ограничен `AniMesh.dll!FUN_10005810` передаётся только видимое значение `min(stage, count - 1)`. +Rust API `update_loaded_mission_control_node_stage(engine, handle, key, +update_effects)` переносит отдельный вызов `0x11220` в пределах подтверждённой +арифметики и CPU-модели. Он читает текущие flags, count и parent, копирует долю +родителя по `4` и его Control stage по `8`, записывает изменённую видимую стадию +по правилу исходного `AniMesh+0x5810`, ставит flag `0x200` при переходе и +сохраняет текущее количество в `processed_amount_bits`. Начальные снимки не +меняются. Возвращаемый bool означает только смену Control stage: при неизменной +стадии обработанное количество всё равно обновляется. Модельный `+0x124` при +наследовании доли не переписывается. + +Flags `0x1c0` вместе с `0x10` и `0xc0` дают нативный ранний выход до чтения +mesh metadata и обновления `+0x0c`. Когда эти же flags требуют lifecycle +callback, API возвращает `UnsupportedNodeLifecycle`. Переход при +`update_effects = true` требует не только создания FX, но и host notifications +через `0x11af0`; пока он возвращает `UnsupportedNodeEffects`. Если стадия не +меняется, нативный код пропускает эту ветвь и такой вызов поддержан. +Переход через sentinel при снятом flag `2` требует `0x11920` и возвращает +`UnsupportedNodeVisibility`. Неверные parent/count, неизвестное состояние +модели и неподдержанные доли также отклоняются. Все эти ошибки сохраняют +прежнее состояние, включая ещё не скопированную долю родителя. + +Здесь поддержаны конечные нормальные доли `0..1` и ноль. У инструкции `FILD` +целый count остаётся точным до `FMUL`; предварительный cast count в `f32` +может изменить ответ. Native вектор `count = 16777217`, `ratio = 0.75` получает +Control/visible stage `4194304`; ранний cast давал бы `4194305`. Rust умножает +целый count на 24-битную мантиссу доли в `u64`, округляет произведение один раз +и точно масштабирует его степенью двойки. Это проверка арифметики с +искусственным большим count, а не число стадий в игровых ресурсах. + +Native oracle и Rust-тест также проверяют двухстадийный случай `ratio = 0.5` +со стадией `1`, обновление только обработанного количества при неизменной +стадии и отсутствие такого обновления при защищённом раннем выходе. Licensed +тест повторяет отдельный переход `0 → 1` на двухстадийном узле настоящего +`min_l_n1.dat`, с `update_effects = false`. Полный игровой вызов не подменяется +этим режимом: FX, lifecycle, видимость и обход потомков ещё требуют реализации. + После перехода родительского ряда `FUN_10011130` находит более поздние строки с его индексом в поле parent. Обход идёт в глубину: сначала ребёнок и всё его поддерево, затем следующий ребёнок. Рост стадии или истощение родителя снимает @@ -1061,7 +1099,8 @@ SHA-256 `65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`, RVA `0x68a0`) читает три DLL scale globals, которые после LoadLibrary в oracle равны `1.0`. В Rust `initial_amount_bits` сохраняет это общее начальное количество отдельно от текущего и последнего обработанного. Прямой delta API -меняет только текущее количество; стадийный update пока не перенесён. +меняет только текущее количество; отдельный стадийный API обновляет +обработанное и текущую стадию в подтверждённой области. Licensed full-load regression `full_load_builds_effective_control_device_table_for_mine_unit_dat` проверяет значения настоящего `min_l_n1.dat`: корневой provider row `0` даёт @@ -1070,7 +1109,9 @@ Licensed full-load regression `full_load_builds_effective_control_device_table_f строки tag-26 устройства вдвое. Rust `0xe00` и `0x1002` дают `0.5`, и обычный шаг runtime сохраняет эту долю. Это вызов прямого API, а не полный живой Control tick. Проекция поля модели `+0x124` также получает `0.5`, а её видимая -стадия остаётся `0`; GPU/window path этим тестом не исполняется. +стадия этого ресурсного узла остаётся `0`. Затем другой, двухстадийный узел +получает долю `0.5` и отдельный stage API переводит его из `0` в `1`. +GPU/window path этим тестом не исполняется. Native-часть этих ABI проверок воспроизводит `behavior_distributor_tick_native_oracle.cs` на синтетически выделенной памяти diff --git a/docs/tomes/02-architecture.md b/docs/tomes/02-architecture.md index 67dc93d..a0c72b6 100644 --- a/docs/tomes/02-architecture.md +++ b/docs/tomes/02-architecture.md @@ -362,8 +362,10 @@ CollObject, Control и PhysicalModel, пропуская отсутствующ [техническом разборе](../appendices/script-vm.md#доставка-игровых-часов-в-control). Статическая цепочка ещё не доказывает исполнение активного Control update на миссии. Rust сохраняет исходные и текущие provider-поля отдельно. Прямой delta -API меняет строку и значение соответствующего CPU-узла модели, но описанный -clock/budget-путь и вывод этого состояния в окно пока не подключены. +API меняет строку и значение соответствующего CPU-узла модели; отдельный +стадийный API обновляет его видимую стадию и обработанное количество в +подтверждённой области. Описанный clock/budget-путь и вывод этого состояния +в окно пока не подключены. В текущем runtime `step_headless` и `frame` получают время очереди явным аргументом `u32`. По нему запускается представленный ресурсный планировщик diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index 6e8d9b1..9dddc71 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -915,8 +915,9 @@ initial_mesh_state: Option Внутри снимка хранятся родительский индекс, переведённые флаги Control и число начальных стадий. Узлы корневой модели добавляются все, а присоединённая `EXTO` -начинает с локального узла `1`. Это описывает исходную настройку: код пока не -переключает стадии в ходе игры и не исполняет полный Control tick. +начинает с локального узла `1`. Это описывает исходную настройку. Отдельный +API уже пересчитывает стадию одного узла, но полный игровой Control tick +пока не исполняется. На той же начальной стадии Control вычисляет количество строки по NDP `max_volume`. Для загрузки Unit DAT проверенные множители равны `1.0`, поэтому @@ -964,6 +965,26 @@ Native oracle соединяет исходные `Control+0x10f30` и `AniMesh+ с критическим флагом `1` пока возвращает ошибку без изменения состояния: эта ветвь требует полного lifecycle объекта, а не только нового количества. +Стадия пересчитывается отдельным вызовом +`update_loaded_mission_control_node_stage`. Для трёх состояний доли `1`, `0.5` +и `0.1` выбирают стадии `0`, `1` и `2`. Нулевая доля даёт служебную стадию +Control `3`, но в модель записывается последняя видимая стадия `2`. Поэтому +`current_stage` и `mesh_state.visible_stage` хранятся отдельно. Флаги `4` и `8` +могут брать соответственно долю или стадию родителя; доля самой модели этим +пересчётом не переписывается. + +После успешного вызова обработанное количество становится равным текущему, +даже если стадия осталась прежней. Истощённые защищённые узлы сохраняют +нативный ранний выход и не обновляют этот снимок. Неизвестные parent/count, +неподдержанные lifecycle-вызовы, FX и пересчёт геометрии на границе истощения +возвращают ошибку до записи. Аргумент `update_effects` сохраняет нативный смысл: +его нельзя молча отключать при включении полноценного игрового цикла. + +На настоящем двухстадийном узле `min_l_n1.dat` тест уменьшает количество вдвое, +явно вызывает стадийный API без FX и получает Control stage `1`, видимую стадию +модели `1` и обновлённое обработанное количество. Это проверка отдельной +операции на ресурсах, а не переключение кадра в игровом окне. + На исходном `min_l_n1.dat` прямое уменьшение количества строки ресурсного устройства вдвое меняет ответы `0xe00` и Behavior `0x1002` с `1.0` на `0.5`. Обычный последующий шаг runtime сохраняет это состояние. Это воспроизводимый @@ -990,7 +1011,8 @@ Control пересчитывает дочерние строки в глубин `[102, 570]`. В проверенных векторах одинаковые данные при скрытии и повторном включении восстановили суммы; переходы с разными слотами — нет. Эти проверки исполняют исходные DLL на тестовых строках, но не являются запуском Control на -полной миссии; Rust пока не выполняет эти переходы стадий и видимости. +полной миссии. Rust выполняет отдельный стадийный шаг в подтверждённой области, +но обход потомков, пересчёт видимости и остальные callbacks ещё не подключены. Прямой путь изменения количества остаётся отдельной операцией. Подробные границы приведены в [техническом приложении](../appendices/script-vm.md#родители-стадии-и-видимость-provider-узлов).