feat(runtime): propagate Control node stage changes
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s

This commit is contained in:
Valentin Popov committed 2026-10-29 17:55:55 +04:00
1 parent 0c31c34b57
commit be4031cb54
5 files changed
+410 -47

No files matched your search

+313 -4
View File
@@ -2240,6 +2240,32 @@ pub fn update_loaded_mission_control_node_stage(
update_control_node_stage(table, key, update_effects) 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( fn update_control_node_stage(
table: &mut MissionControlDeviceTableState, table: &mut MissionControlDeviceTableState,
key: u32, key: u32,
@@ -2346,6 +2372,120 @@ fn update_control_node_stage(
Ok(changed) 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 { fn control_stage_product(count: u32, ratio: f32) -> f32 {
if ratio == 0.0 { if ratio == 0.0 {
return 0.0; return 0.0;
@@ -5278,6 +5418,10 @@ mod tests {
update_loaded_mission_control_node_stage(&mut engine, handle, 1, false), update_loaded_mission_control_node_stage(&mut engine, handle, 1, false),
Err(ControlResourceAccessError::MissionUnavailable) Err(ControlResourceAccessError::MissionUnavailable)
); );
assert_eq!(
propagate_loaded_mission_control_node_stage(&mut engine, handle, 1, false),
Err(ControlResourceAccessError::MissionUnavailable)
);
let stale = ObjectHandle { let stale = ObjectHandle {
generation: handle.generation.wrapping_add(1), generation: handle.generation.wrapping_add(1),
@@ -5321,6 +5465,12 @@ mod tests {
fparkan_world::WorldError::StaleHandle 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"); fparkan_world::request_delete(&mut engine.world, handle).expect("delete object");
assert_eq!( assert_eq!(
@@ -5329,6 +5479,12 @@ mod tests {
fparkan_world::WorldError::StaleHandle 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!( assert_eq!(
apply_loaded_mission_control_node_delta(&mut engine, handle, 1, -1.0), 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::<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] #[test]
fn common_factory_into_overrides_preserve_native_constructor_defaults() { fn common_factory_into_overrides_preserve_native_constructor_defaults() {
const INTO: u32 = u32::from_le_bytes(*b"INTO"); 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]; let draft = &loaded_mission_object_drafts(&engine).expect("object drafts")[0];
assert_eq!(draft.unit_components.len(), 6); assert_eq!(draft.unit_components.len(), 6);
assert_eq!(draft.unit_components[0].immediate_child_count, 5); 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.len(), 1);
assert_eq!(tables[0].original_id, Some(OriginalObjectId(0))); assert_eq!(tables[0].original_id, Some(OriginalObjectId(0)));
assert!(tables[0].node_provider_dirty); assert!(tables[0].node_provider_dirty);
let control_handle = tables[0].handle; 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!( assert_eq!(
query_loaded_mission_control_resource(&engine, control_handle, 1) query_loaded_mission_control_resource(&engine, control_handle, 1)
.expect("licensed selector 1") .expect("licensed selector 1")
@@ -7219,13 +7528,13 @@ mod tests {
) )
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert!(update_loaded_mission_control_node_stage( propagate_loaded_mission_control_node_stage(
&mut mine_engine, &mut mine_engine,
mine_profile.handle, mine_profile.handle,
stage_key, stage_key,
false false,
) )
.unwrap()); .expect("licensed Control descendant stage propagation");
let MissionControlNodeProviderTableStatus::Available(rows) = let MissionControlNodeProviderTableStatus::Available(rows) =
&loaded_mission_control_device_tables(&mine_engine).unwrap()[0].node_provider_status &loaded_mission_control_device_tables(&mine_engine).unwrap()[0].node_provider_status
else { else {
@@ -1131,7 +1131,7 @@ internal static class BehaviorDistributorTickOracle {
new float[] { 25.0f, 10.0f, 5.0f, 12.0f, 7.0f }, new float[] { 25.0f, 10.0f, 5.0f, 12.0f, 7.0f },
new byte[] { 0x02, 0x02, 0x02, 0x02, 0x02 }); new byte[] { 0x02, 0x02, 0x02, 0x02, 0x02 });
const int rowCount = 5; 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[] stages = { 4, 2, 3, 2, 2 };
uint[] flags = { 0x02, 0x42, 0x02, 0x102, 0x02 }; uint[] flags = { 0x02, 0x42, 0x02, 0x102, 0x02 };
for (int index = 0; index < rowCount; index++) { for (int index = 0; index < rowCount; index++) {
@@ -1169,14 +1169,14 @@ internal static class BehaviorDistributorTickOracle {
IntPtr parent = table.Rows; IntPtr parent = table.Rows;
IntPtr protected40 = IntPtr.Add(table.Rows, 0x2c); 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 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 parentMeshRow = meshRows;
IntPtr bit40MeshRow = IntPtr.Add(meshRows, 0x130); 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 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 && Expect(ReadFloat(parent, 0x08) == 25.0f && ReadFloat(parent, 0x0c) == 25.0f &&
unchecked((uint)Marshal.ReadInt32(parent, 0x18)) == 3, unchecked((uint)Marshal.ReadInt32(parent, 0x18)) == 3,
"Control 0x11130 updates the parent stage and processed amount first"); "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, 0x18)) == 0 &&
(unchecked((uint)Marshal.ReadInt32(protected40, 0x28)) & 0x50) == 0x50, (unchecked((uint)Marshal.ReadInt32(protected40, 0x28)) & 0x50) == 0x50,
"Protected bit 0x40 does not stop child depletion, while its depleted-stage early return remains visible"); "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 && Expect(ReadFloat(protected100, 0x08) == 0.0f && ReadFloat(protected100, 0x0c) == 0.0f &&
unchecked((uint)Marshal.ReadInt32(protected100, 0x18)) == 2, 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, 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 && 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, (unchecked((uint)Marshal.ReadInt32(sibling, 0x28)) & 0x100) != 0,
"An unprotected sibling retains the native damaged flag while updating its stage"); "An unprotected sibling retains the native damaged flag while updating its stage");
Expect(unchecked((uint)Marshal.ReadInt32(parentMeshRow, 0x1c)) == 3 && Expect(unchecked((uint)Marshal.ReadInt32(parentMeshRow, 0x1c)) == 3 &&
unchecked((uint)Marshal.ReadInt32(bit40MeshRow, 0x1c)) == unchecked((uint)-99) && 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(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"); "ANI slot 0x44 reaches native AniMesh RVA 0x5810 for changed visible stages only");
Expect(controlNodeUpdateCount == 4 && Expect(controlNodeUpdateCount == 4 &&
String.Join(",", controlNodeUpdateIndices.ConvertAll(index => index.ToString()).ToArray()) == "1,2,3,4" && String.Join(",", controlNodeUpdateIndices.ConvertAll(index => index.ToString()).ToArray()) == "1,3,4,2" &&
String.Join(",", controlNodeUpdateFlags.ConvertAll(value => value.ToString("x")).ToArray()) == "42,82,102,102" && String.Join(",", controlNodeUpdateFlags.ConvertAll(value => value.ToString("x")).ToArray()) == "42,102,102,102" &&
!controlNodeUpdateOwnerMismatch, !controlNodeUpdateOwnerMismatch,
"Control 0x11130 traverses descendants depth-first, checking callback owners and protected flags"); "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 && aniMeshNodeLifecycleCallbacks.Count == 0 &&
!aniMeshCallbackOwnerMismatch && !nativeAniMeshStageCallbackMissing, !aniMeshCallbackOwnerMismatch && !nativeAniMeshStageCallbackMissing,
"Control descendant update preserves the native callback order and stage bounds"); "Control descendant update preserves the native callback order and stage bounds");
Expect(String.Join(",", controlProviderCallbackTrace.ToArray()) == 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"); "Control 0x11130 keeps amount, lifecycle, and visual-stage callbacks in native depth-first order");
Expect(ReadFloat(table.Owner, 0x590) == 25.0f, Expect(ReadFloat(table.Owner, 0x590) == 25.0f,
"Control 0x10f30 keeps the aggregate amount in sync after recursive child depletion"); "Control 0x10f30 keeps the aggregate amount in sync after recursive child depletion");
Console.WriteLine("native-control-parent-propagation: controlHash=" + ExpectedControlSha256 + 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())); " callbacks=" + String.Join(",", controlProviderCallbackTrace.ToArray()));
activeControlProviderRows = IntPtr.Zero; activeControlProviderRows = IntPtr.Zero;
activeAniMeshInterface = IntPtr.Zero; activeAniMeshInterface = IntPtr.Zero;
+20 -6
View File
@@ -351,11 +351,24 @@ MSH-часть проверки подтверждает начальный сн
Control. Обработанное количество при ней остаётся прежним. Отдельный Control. Обработанное количество при ней остаётся прежним. Отдельный
`update_loaded_mission_control_node_stage` реализует наследование parent ratio `update_loaded_mission_control_node_stage` реализует наследование parent ratio
и stage, ceiling-арифметику, CPU visible stage и копию processed amount. и stage, ceiling-арифметику, CPU visible stage и копию processed amount.
Обход потомков `0x11130` пока не перенесён. Нужные lifecycle/visibility/FX Отдельная `propagate_loaded_mission_control_node_stage` переносит `0x11130`:
callbacks дают явные ошибки до записи; `update_effects = true` поддержан после обновления строки она сканирует более поздние provider-строки и обходит
только без фактической смены стадии. Licensed двухстадийный узел шахты найденные поддеревья в native depth-first порядке. Для родителей
подтверждает отдельный переход `0 → 1` при доле `0.5`, без FX и window path. Гибель root/critical узла и неизвестные flags/aggregate дают явную `[-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` только для Rust query обращается к загруженному объекту и поддерживает `0xe00` только для
текущего descriptor tag `26` с factory tag `26`; другие активные typed tags и текущего descriptor tag `26` с factory tag `26`; другие активные typed tags и
@@ -364,7 +377,8 @@ Licensed `min_l_n1.dat` подтверждает изменение `0xe00` и B
`1.0` до `0.5` после явного уменьшения количества вдвое; это ещё не `1.0` до `0.5` после явного уменьшения количества вдвое; это ещё не
автоматический бюджетный цикл. автоматический бюджетный цикл.
Native oracle проверил изолированные пути распределения, обновления класса `4`, 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. Отдельный visibility transition `0x11920` с исходными AniMesh callbacks. Отдельный
бюджетный вектор исполняет `0x2d340` → `0x2dca0` → tag 15 `0x22bb0` → бюджетный вектор исполняет `0x2d340` → `0x2dca0` → tag 15 `0x22bb0` →
`0x10ba0`: подтверждены коэффициенты `0.5` и `1`, точные amount/ratio и `0x10ba0`: подтверждены коэффициенты `0.5` и `1`, точные amount/ratio и
+28 -10
View File
@@ -695,8 +695,8 @@ provider остаётся с заданным тестом значением `0
текущего Control stage/sentinel. Неизвестный узел даёт ошибку без частичной текущего Control stage/sentinel. Неизвестный узел даёт ошибку без частичной
записи; callback отражён в CPU-проекции, но GPU/window path ещё не подключён. записи; callback отражён в CPU-проекции, но GPU/window path ещё не подключён.
Delta API не вызывает стадийный проход. Отдельный single-node API для Delta API не вызывает стадийный проход. Отдельный single-node API для
`0x11220` описан в разделе о стадиях; обход `0x11130`, видимость и FX остаются `0x11220` и отдельный API обхода потомков `0x11130` описаны в разделе о
вне этого исполнения. стадиях; видимость и FX callbacks остаются вне этого исполнения.
Истощение корня или критического узла возвращает `UnsupportedNodeLifecycle` Истощение корня или критического узла возвращает `UnsupportedNodeLifecycle`
до записи: состояние владельца `+0x550`, отложенная гибель и сопутствующие до записи: состояние владельца `+0x550`, отложенная гибель и сопутствующие
callbacks ещё не реализованы. Отсутствие MSH flags, provider aggregate, callbacks ещё не реализованы. Отсутствие MSH flags, provider aggregate,
@@ -964,15 +964,33 @@ Native oracle и Rust-тест также проверяют двухстади
стадии и отсутствие такого обновления при защищённом раннем выходе. Licensed стадии и отсутствие такого обновления при защищённом раннем выходе. Licensed
тест повторяет отдельный переход `0 → 1` на двухстадийном узле настоящего тест повторяет отдельный переход `0 → 1` на двухстадийном узле настоящего
`min_l_n1.dat`, с `update_effects = false`. Полный игровой вызов не подменяется `min_l_n1.dat`, с `update_effects = false`. Полный игровой вызов не подменяется
этим режимом: FX, lifecycle, видимость и обход потомков ещё требуют реализации. этим режимом: FX, lifecycle и видимость всё ещё требуют реализации; обход
потомков доступен отдельным CPU API и пока не подключён к игровому циклу.
После перехода родительского ряда `FUN_10011130` находит более поздние строки `FUN_10011130` сохраняет старую стадию, вызывает `0x11220`, затем сохраняет
с его индексом в поле parent. Обход идёт в глубину: сначала ребёнок и всё его обновлённые flags родителя для всего прохода. В исследованной сборке
поддерево, затем следующий ребёнок. Рост стадии или истощение родителя снимает `0x11130` вызывает `0x11220` по RVA `0x11156`; caller-ы `0x11130` находятся в
количество с дочерних строк; защитные флаги `0x40` и `0x100` меняют часть `0x10ba0`, `0x12a40`, `0x12ce0`, `0x13180` и в самой рекурсивной функции.
обработки флагов, но не останавливают снятие количества и обход потомков в `0x10f30` вызывает `0x11220` напрямую по RVA `0x1110f`.
проверенных входах. Нативный тест задал родителя с дочерними рядами, снял их
количество и проверил порядок вызовов и значение stage snapshot. Затем `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` вызывает Переход между обычной стадией и sentinel `stage == count` вызывает
`Control.dll!FUN_10011920`, только если у строки не установлен флаг `0x02`. `Control.dll!FUN_10011920`, только если у строки не установлен флаг `0x02`.
+29 -8
View File
@@ -997,22 +997,43 @@ clock Control.
даёт `[14, 28, 10]`. При вычитании последовательный способ начинает с последней даёт `[14, 28, 10]`. При вычитании последовательный способ начинает с последней
строки. Так одна таблица описывает и текущее значение, и то, как оно меняется. строки. Так одна таблица описывает и текущее значение, и то, как оно меняется.
У строк есть и связи с родительскими узлами. Когда стадия родителя меняется, У строк есть и связи с родительскими узлами. Нативный `Control.dll` сначала
Control пересчитывает дочерние строки в глубину: ребёнка вместе со всем его обновляет запрошенную строку через `0x11220`, затем `0x11130` ищет её детей
поддеревом, затем следующего ребёнка. Для числа вариантов `count` внутренняя среди строк с большим индексом. Каждый найденный ребёнок обновляется вместе со
стадия вычисляется как `count - ceil(f32(count * ratio))`, а видимое значение своим поддеревом, и только потом обход продолжается со следующим соседом.
ограничивается диапазоном модели. Например, при трёх стадиях доли Например, для родителей `[-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 `[0.1, 0.4, 0.5, 1, 0]` дали `[2, 1, 1, 0, 3]`; последнее значение — sentinel
истощения, а в модель передаётся индекс не выше `count - 1`. Нативные проверки истощения, а в модель передаётся индекс не выше `count - 1`. Нативные проверки
подтвердили порядок обхода и передачу состояния в AniMesh. подтвердили арифметику и порядок обхода.
После обновления родителя Control сравнивает его старую стадию с новой и
проверяет флаг истощения `0x10`. Если стадия выросла либо строка истощена,
количество каждого ребёнка полностью уменьшается тем же путём, что и обычная
добавка ресурса. Для ребёнка без защитной маски родительские флаги `0xc0`
задают флаг `0x80`; в обычной ветви переход ставит `0x100` при ухудшении и
очищает у ребёнка `0x80`. Маска `0x140` запрещает только изменение этих флагов:
уменьшение количества и рекурсивный вызов всё равно выполняются. Дочерние
стадии пересчитываются и тогда, когда родитель не ухудшился.
Выбранная стадия также задаёт, какие данные MSH попадают в пересчёт видимости. Выбранная стадия также задаёт, какие данные MSH попадают в пересчёт видимости.
У одного синтетического узла первый слот давал `[42, 330]`, а слот другой стадии — У одного синтетического узла первый слот давал `[42, 330]`, а слот другой стадии —
`[102, 570]`. В проверенных векторах одинаковые данные при скрытии и повторном `[102, 570]`. В проверенных векторах одинаковые данные при скрытии и повторном
включении восстановили суммы; переходы с разными слотами — нет. Эти проверки включении восстановили суммы; переходы с разными слотами — нет. Эти проверки
исполняют исходные DLL на тестовых строках, но не являются запуском Control на исполняют исходные 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-узлов). Подробные границы приведены в [техническом приложении](../appendices/script-vm.md#родители-стадии-и-видимость-provider-узлов).