From 6eb1dcaf7036e4e1702de15d0dd02181a3577852 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 29 Oct 2026 17:08:08 +0400 Subject: [PATCH] feat(runtime): publish provider values into model node state --- crates/fparkan-runtime/src/lib.rs | 76 +++++++++++++++++-- ...behavior_distributor_tick_native_oracle.cs | 44 +++++++++-- docs/appendices/knowledge-boundaries.md | 8 +- docs/appendices/script-vm.md | 27 ++++++- docs/tomes/02-architecture.md | 4 +- docs/tomes/06-behavior.md | 19 ++++- 6 files changed, 156 insertions(+), 22 deletions(-) diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 9ae4e84..8e8224b 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -222,6 +222,15 @@ pub struct MissionControlNodeInitialMeshState { pub stage_count: u32, } +/// Mutable projection of the provider row's matching AniMesh node fields. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct MissionControlNodeMeshState { + /// Node value at AniMesh `+0x124`, written by selector `0x200`. + pub type1_value_bits: u32, + /// Visible MSH stage at AniMesh `+0x1c`; distinct from Control's exhaustion sentinel. + pub visible_stage: u32, +} + /// 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 @@ -256,12 +265,13 @@ pub struct MissionControlNodeProviderRow { pub current_flags: Option, /// 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. + pub mesh_state: Option, } -/// Node-property publication required after a provider amount changes. +/// Node-property publication applied to CPU model state after an amount change. /// -/// The model-node callback is native selector `0x200`; the caller must publish -/// this value separately from the later stage and visibility update. +/// Records native selector `0x200`; stage and visibility updates are separate. #[derive(Clone, Copy, Debug, Eq, PartialEq)] #[must_use] pub struct ControlNodeProviderPublication { @@ -2157,15 +2167,16 @@ pub fn set_loaded_mission_control_multiplier( /// 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, +/// Updates amount, ratio, aggregate, exhaustion flag, dirty state, and the CPU +/// model node's selector-`0x200` value, returning the applied 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. +/// on error. This updates CPU model state, not GPU uploads or window rendering. pub fn apply_loaded_mission_control_node_delta( engine: &mut Engine, handle: ObjectHandle, @@ -2262,8 +2273,16 @@ fn apply_control_node_delta( 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)?; + let mesh = row + .mesh_state + .as_mut() + .ok_or(ControlResourceAccessError::UnsupportedField { + factory_tag: 0, + field: "model node state", + })?; row.current_amount_bits = amount.to_bits(); row.current_type1_value_bits = ratio.to_bits(); + mesh.type1_value_bits = ratio.to_bits(); row.current_flags = Some(if amount == 0.0 { flags | 0x10 } else { @@ -3755,6 +3774,7 @@ fn append_control_node_provider_rows( current_type1_value_bits: 0x3f80_0000, current_flags: None, initial_mesh_state: None, + mesh_state: None, }); } } @@ -3820,6 +3840,10 @@ 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); + row.mesh_state = row.initial_mesh_state.map(|_| MissionControlNodeMeshState { + type1_value_bits: row.initial_type1_value_bits, + visible_stage: 0, + }); } } @@ -4321,6 +4345,7 @@ mod tests { current_type1_value_bits: initial_type1_value_bits, current_flags: None, initial_mesh_state: None, + mesh_state: None, } } @@ -5157,6 +5182,10 @@ mod tests { row.current_amount_bits = 40.0_f32.to_bits(); row.processed_amount_bits = 40.0_f32.to_bits(); row.current_flags = Some(0); + row.mesh_state = Some(MissionControlNodeMeshState { + type1_value_bits: 1.0_f32.to_bits(), + visible_stage: 77, + }); let mut device = synthetic_effective_control_device( 26, 26, @@ -5198,6 +5227,13 @@ mod tests { }; 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].mesh_state, + Some(MissionControlNodeMeshState { + type1_value_bits: ratio.to_bits(), + visible_stage: 77 + }) + ); 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; @@ -5260,6 +5296,27 @@ mod tests { )); assert_eq!(table, before); table.node_provider_amount_bits = total; + let MissionControlNodeProviderTableStatus::Available(rows) = + &mut table.node_provider_status + else { + panic!() + }; + let mesh = rows[1].mesh_state.take(); + let before = table.clone(); + assert!(matches!( + apply_control_node_delta(&mut table, 1, 1.0), + Err(ControlResourceAccessError::UnsupportedField { + field: "model node state", + .. + }) + )); + assert_eq!(table, before); + let MissionControlNodeProviderTableStatus::Available(rows) = + &mut table.node_provider_status + else { + panic!() + }; + rows[1].mesh_state = mesh; let before = table.clone(); assert_eq!( apply_control_node_delta(&mut table, 1, -100.0), @@ -6794,6 +6851,13 @@ mod tests { }; assert_eq!(mine_rows[key as usize].initial_amount_bits, initial); assert_eq!(mine_rows[key as usize].processed_amount_bits, initial); + assert_eq!( + mine_rows[key as usize].mesh_state, + Some(MissionControlNodeMeshState { + type1_value_bits: 0.5_f32.to_bits(), + visible_stage: 0, + }) + ); } #[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 d35081f..61cbc01 100644 --- a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs +++ b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs @@ -125,6 +125,7 @@ internal static class BehaviorDistributorTickOracle { [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate IntPtr NativeControlResourceComponentOverride(IntPtr self, int deviceIndex, IntPtr typedRecord); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int NativeControlAggregateQuery(IntPtr self, uint selector, IntPtr result); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int NativeControlAggregateSet(IntPtr self, uint selector, IntPtr value); + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeAniMeshNodeConstructor(IntPtr self, IntPtr model, IntPtr source, uint component, IntPtr meshData); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeControlProviderAmountUpdate(IntPtr self, int nodeIndex, float delta, uint animationFlag); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeControlProviderAmountDistribute(IntPtr self, float delta, uint mode, uint flags); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeControlContactRateUpdate(IntPtr self, float deltaSeconds); @@ -696,7 +697,7 @@ internal static class BehaviorDistributorTickOracle { RunNativeControlProviderInitialStateCase(controlModule, aniMeshModule); RunNativeControlIntoOverrideCase(controlModule); RunNativeControlResourceDeviceCase(controlModule); - RunNativeControlProviderLiveStateCase(controlModule); + RunNativeControlProviderLiveStateCase(controlModule, aniMeshModule); RunNativeControlProviderStageArithmeticCase(controlModule, aniMeshModule); RunNativeControlProviderHierarchyPropagationCase(controlModule, aniMeshModule); RunNativeControlProviderVisibilityCase(controlModule, aniMeshModule); @@ -861,7 +862,7 @@ internal static class BehaviorDistributorTickOracle { } } - private static void RunNativeControlProviderLiveStateCase(IntPtr module) { + private static void RunNativeControlProviderLiveStateCase(IntPtr module, IntPtr aniMeshModule) { NativeControlProviderRowConstructor constructProviderRow = (NativeControlProviderRowConstructor) Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, ControlProviderRowConstructorRva), typeof(NativeControlProviderRowConstructor)); @@ -890,7 +891,29 @@ internal static class BehaviorDistributorTickOracle { IntPtr providerInterface = IntPtr.Add(owner, 4); IntPtr providerVtable = IntPtr.Add(module, ControlProviderInterfaceVtableRva); - IntPtr callbackObject = fixture.Allocate(IntPtr.Size); + IntPtr callbackObject = fixture.Allocate(0x1b0); + IntPtr meshRows = fixture.Allocate(2 * 0x130); + NativeAniMeshNodeConstructor constructNode = (NativeAniMeshNodeConstructor)Marshal.GetDelegateForFunctionPointer( + IntPtr.Add(aniMeshModule, 0x123e0), typeof(NativeAniMeshNodeConstructor)); + for (int node = 0; node < 2; node++) { + constructNode(IntPtr.Add(meshRows, node * 0x130), IntPtr.Zero, IntPtr.Zero, 0, IntPtr.Zero); + Expect(Marshal.ReadInt32(meshRows, node * 0x130 + 0x124) == FloatBits(1.0f) && + Marshal.ReadInt32(meshRows, node * 0x130 + 0x1c) == 0, + "original AniMesh node constructor initializes provider value and visible stage"); + } + Marshal.WriteIntPtr(callbackObject, 0x1a8, meshRows); + Marshal.WriteInt32(callbackObject, 0x1ac, 2); + Marshal.WriteInt32(meshRows, 0x124, FloatBits(-7.0f)); + Marshal.WriteInt32(meshRows, 0x130 + 0x1c, 77); + NativeControlNodeUpdateCallback setNodeValue = (NativeControlNodeUpdateCallback)Marshal.GetDelegateForFunctionPointer( + IntPtr.Add(aniMeshModule, 0x5750), typeof(NativeControlNodeUpdateCallback)); + NativeControlNodeUpdateCallback forwardNodeValue = delegate(IntPtr self, uint node, uint selector, uint bits) { + OnControlNodeUpdate(self, node, selector, bits); + setNodeValue(self, node, selector, bits); + }; + setNodeValue(callbackObject, 1, 0x201, unchecked((uint)FloatBits(99.0f))); + Expect(ReadFloat(meshRows, 0x130 + 0x124) == 1.0f, + "AniMesh node-value setter ignores selectors other than 0x200"); IntPtr callbackVtable = fixture.Allocate(0x38); IntPtr device = fixture.Allocate(0x94); IntPtr row = IntPtr.Add(rows, 0x2c); // Keep the root row untouched. @@ -909,7 +932,7 @@ internal static class BehaviorDistributorTickOracle { Marshal.WriteInt32(row, 0x14, FloatBits(0.4f)); Marshal.WriteIntPtr(callbackObject, 0, callbackVtable); - Marshal.WriteIntPtr(callbackVtable, 0x34, Marshal.GetFunctionPointerForDelegate(ControlNodeUpdateCallback)); + Marshal.WriteIntPtr(callbackVtable, 0x34, Marshal.GetFunctionPointerForDelegate(forwardNodeValue)); Marshal.WriteIntPtr(owner, 0x20, callbackObject); Marshal.WriteInt32(device, 0x04, 1); @@ -922,6 +945,9 @@ internal static class BehaviorDistributorTickOracle { Expect(ReadFloat(row, 0x08) == 60.0f && ReadFloat(row, 0x14) == 0.6f && ReadFloat(owner, 0x590) == 60.0f, "Control native amount update clamps the node amount, updates the normalized provider value, and keeps the aggregate amount in sync"); + Expect(ReadFloat(meshRows, 0x130 + 0x124) == 0.6f && ReadFloat(meshRows, 0x124) == -7.0f && + Marshal.ReadInt32(meshRows, 0x130 + 0x1c) == 77, + "Control amount update reaches original AniMesh setter and changes only its target node value"); Expect(controlNodeUpdateCount == 1 && controlNodeUpdateIndex == 1 && controlNodeUpdateSelector == 0x200 && controlNodeUpdateValueBits == unchecked((uint)FloatBits(0.6f)), "Control native amount update publishes the normalized provider state through the existing node callback"); @@ -960,11 +986,17 @@ internal static class BehaviorDistributorTickOracle { 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, + controlNodeUpdateCount == 5 && (Marshal.ReadInt32(row, 0x28) & 0x10) == 0 && + ReadFloat(meshRows, 0x130 + 0x124) == 0.1f, "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"); + Console.WriteLine("native-control-to-animesh-node-value: AniMeshCtor=0x123e0 setter=0x5750 rowStride=0x130 value=+0x124" + + " selector=0x200 other-node/stage=unchanged signed-zero=no-publication"); + GC.KeepAlive(constructNode); + GC.KeepAlive(setNodeValue); + GC.KeepAlive(forwardNodeValue); GC.KeepAlive(constructProviderRow); GC.KeepAlive(update); GC.KeepAlive(getter); @@ -1046,6 +1078,8 @@ internal static class BehaviorDistributorTickOracle { unchecked((uint)Marshal.ReadInt32(rows, 0x2c + 0x14)) == 0x3e800000 && unchecked((uint)Marshal.ReadInt32(rows, 3 * 0x2c + 0x14)) == 0x3e800000, "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" && String.Join(",", aniMeshNodeLifecycleCallbacks.ToArray()) == "7:101:4" && !aniMeshCallbackOwnerMismatch && !controlNodeUpdateOwnerMismatch && !nativeAniMeshStageCallbackMissing, diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index e2dbffe..227e2a4 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -343,9 +343,11 @@ MSH-часть проверки подтверждает начальный сн `Control.dll+0xf940` кладёт результат selector `3` в текущее и обработанное количество; при подтверждённых единичных множителях это raw `max_volume` NDP. Прямой API `apply_loaded_mission_control_node_delta` меняет amount, долю, флаг -истощения, aggregate и dirty, возвращая обязательную публикацию `0x200` для -узла. Он пока не доставляет её в AniMesh и не выполняет пересчёт стадий, -видимости, FX или время Control. Поэтому обработанное количество остаётся +истощения, aggregate и dirty, применяя публикацию `0x200` к CPU-проекции +соответствующего узла модели. Исходный `AniMesh.dll+0x5750` подтверждён в +связке с `Control+0x10f30`: меняется только поле узла `+0x124`, а стадия и +соседние узлы сохраняются. Эта проекция ещё не подключена к GPU/window path +и не выполняет пересчёт стадий, видимости, FX или время Control. Поэтому обработанное количество остаётся прежним. Гибель root/critical узла и неизвестные flags/aggregate дают явную ошибку до записи; неподдержанные численные диапазоны тоже отклоняются. diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 314d86f..6f52556 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -672,8 +672,28 @@ native `+0x590` получает `max(0, total + (new - old))`, `+0x14` — до это начальное значение. Rust сохраняет начальные и текущие поля отдельно. Возвращаемая -`ControlNodeProviderPublication` содержит ключ и значение для обязательного -callback узла с selector `0x200`; автоматической доставки в AniMesh пока нет. +`ControlNodeProviderPublication` содержит ключ и значение уже применённого +callback selector `0x200` в CPU-проекции узла. У `AniMesh.dll` с SHA-256 +`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e` +primary vtable RVA `0x2057c`, slot `+0x34`, указывает на `0x5750`. +Этот stdcall-обработчик принимает четыре DWORD: owner, ordinal, selector, +raw float value. Только selector `0x200` записывает float в +`owner[+0x1a8] + ordinal * 0x130 + 0x124`. Видимая стадия `+0x1c` при этом не +меняется. Конструктор строки RVA `0x123e0` задаёт начальные `+0x124 = 1.0` +и `+0x1c = 0`. + +Native live-provider oracle использует оригинальный конструктор этих строк и +передаёт callback `Control+0x10f30` в исходный `AniMesh+0x5750` через +прозрачную записывающую трассу обёртку. Проверены значения `0.6` и `0.1`, +отсутствие записи у соседнего узла и прежняя стадия `77` у целевого узла; +selector `0x201` ничего не меняет. При signed-zero no-publication случае +provider остаётся с заданным тестом значением `0.25`, а модель сохраняет +последнее опубликованное `0.1`. Ещё один native вектор показывает, что +стадийное наследование доли родителя тоже не переписывает `+0x124` модели. +Поэтому Rust хранит `mesh_state.type1_value_bits` отдельно от +`current_type1_value_bits` provider, а `mesh_state.visible_stage` — отдельно от +будущего Control stage/sentinel. Неизвестный узел даёт ошибку без частичной +записи; callback отражён в CPU-проекции, но GPU/window path ещё не подключён. Полный пересчёт стадии `0x11130`/`0x11220`, видимости и FX также не вызывается. Истощение корня или критического узла возвращает `UnsupportedNodeLifecycle` до записи: состояние владельца `+0x550`, отложенная гибель и сопутствующие @@ -1049,7 +1069,8 @@ Licensed full-load regression `full_load_builds_effective_control_device_table_f Проверка использует исходные CTL/NDP/MSH, затем явно уменьшает количество строки tag-26 устройства вдвое. Rust `0xe00` и `0x1002` дают `0.5`, и обычный шаг runtime сохраняет эту долю. Это вызов прямого API, а не полный живой -Control tick или автоматическая публикация изменения в AniMesh. +Control tick. Проекция поля модели `+0x124` также получает `0.5`, а её видимая +стадия остаётся `0`; 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 1eaedc7..67dc93d 100644 --- a/docs/tomes/02-architecture.md +++ b/docs/tomes/02-architecture.md @@ -362,8 +362,8 @@ CollObject, Control и PhysicalModel, пропуская отсутствующ [техническом разборе](../appendices/script-vm.md#доставка-игровых-часов-в-control). Статическая цепочка ещё не доказывает исполнение активного Control update на миссии. Rust сохраняет исходные и текущие provider-поля отдельно. Прямой delta -API меняет строку и возвращает публикацию узла, но описанный clock/budget-путь -пока не подключён. +API меняет строку и значение соответствующего CPU-узла модели, но описанный +clock/budget-путь и вывод этого состояния в окно пока не подключены. В текущем runtime `step_headless` и `frame` получают время очереди явным аргументом `u32`. По нему запускается представленный ресурсный планировщик diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index aa2fa09..6e8d9b1 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -945,9 +945,22 @@ let ratio = amount / maximum; Это три отдельные операции над `f32`, с проверкой поддерживаемого численного диапазона до записи состояния. Исходные `initial_*` поля сохраняются; `processed_amount_bits` тоже не меняется, поскольку его обновляет другой -нативный проход — пересчёт стадий. API возвращает обязательную публикацию -`ControlNodeProviderPublication` для узла и selector `0x200`; сам callback -AniMesh и пересчёт стадий этим API не исполняются. Истощение корня либо узла +нативный проход — пересчёт стадий. Публикация selector `0x200` теперь применяется +к CPU-состоянию соответствующего узла модели. Результат +`ControlNodeProviderPublication` описывает уже выполненную запись. Пересчёт +стадий и вывод изменённой модели в окно этим API ещё не запускаются. + +У AniMesh есть собственное поле значения узла, отдельное от доли Control. +Конструктор `AniMesh.dll+0x123e0` начинает его с `1.0`, а видимую стадию — с +`0`. Callback `+0x5750` при selector `0x200` записывает число в поле `+0x124` +строки модели с шагом `0x130`, не меняя её стадию `+0x1c` и соседние узлы. +Native oracle соединяет исходные `Control+0x10f30` и `AniMesh+0x5750`; между +ними стоит только запись диагностической трассы. Прямая добавка `20` к +количеству `40` из максимума `100` записывает `0.6` и в provider, и в узел +модели. Эти поля нельзя объединять: наследование родительской доли в отдельном +стадийном проходе меняет provider, не повторяя callback `0x200`. +В Rust `mesh_state` хранит это отдельное значение и видимую стадию; +неразрешённый узел прерывает изменение до записи остальных полей. Истощение корня либо узла с критическим флагом `1` пока возвращает ошибку без изменения состояния: эта ветвь требует полного lifecycle объекта, а не только нового количества.