aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-29 16:55:55 +0300
committerValentin Popov <valentin@popov.link>2026-10-29 16:55:55 +0300
commitbe4031cb54d9bd88d1bad9cf993d27f914c767fa (patch)
tree4ca18e8f2275be626062d16a3820a87782ca05fe /crates
parent0c31c34b57de4abeef0b1377bbc53a6398b93ffc (diff)
downloadfparkan-be4031cb54d9bd88d1bad9cf993d27f914c767fa.tar.xz
fparkan-be4031cb54d9bd88d1bad9cf993d27f914c767fa.zip
feat(runtime): propagate Control node stage changes
Diffstat (limited to 'crates')
-rw-r--r--crates/fparkan-runtime/src/lib.rs317
-rw-r--r--crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs39
2 files changed, 333 insertions, 23 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),
@@ -5697,6 +5853,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::<Vec<_>>()
+ };
+
+ 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");
for (factory_tag, word_50) in [
@@ -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;