feat(runtime): update Control node stages
Docs Deploy / Build and Deploy MkDocs (push) Successful in 38s

This commit is contained in:
Valentin Popov committed 2026-10-29 17:30:41 +04:00
1 parent 6eb1dcaf70
commit 0c31c34b57
6 files changed
+497 -18

No files matched your search

+390
View File
@@ -263,6 +263,8 @@ pub struct MissionControlNodeProviderRow {
pub current_type1_value_bits: u32, pub current_type1_value_bits: u32,
/// Current native `+0x28` flags, when the initial mesh metadata is known. /// Current native `+0x28` flags, when the initial mesh metadata is known.
pub current_flags: Option<u32>, pub current_flags: Option<u32>,
/// 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. /// MSH state exposed at the same global ordinal, if the mesh projection exists.
pub initial_mesh_state: Option<MissionControlNodeInitialMeshState>, pub initial_mesh_state: Option<MissionControlNodeInitialMeshState>,
/// Current model-node fields, when its global ordinal was resolved. /// Current model-node fields, when its global ordinal was resolved.
@@ -341,6 +343,18 @@ pub enum ControlResourceAccessError {
InvalidNodeProviderAmount { key: u32 }, InvalidNodeProviderAmount { key: u32 },
/// Depleting a root or critical node needs the unimplemented object lifecycle. /// Depleting a root or critical node needs the unimplemented object lifecycle.
UnsupportedNodeLifecycle { key: u32 }, 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. /// A native tag has an e00 callback that has not been implemented here.
UnsupportedCurrentTag { tag: u32 }, UnsupportedCurrentTag { tag: u32 },
/// A selected row lacks a value needed by the native getter or setter. /// 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) 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<bool, 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 })?;
update_control_node_stage(table, key, update_effects)
}
fn update_control_node_stage(
table: &mut MissionControlDeviceTableState,
key: u32,
update_effects: bool,
) -> Result<bool, ControlResourceAccessError> {
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<u32> { fn initial_node_provider_amount(status: &MissionControlNodeProviderTableStatus) -> Option<u32> {
let MissionControlNodeProviderTableStatus::Available(rows) = status else { let MissionControlNodeProviderTableStatus::Available(rows) = status else {
return None; return None;
@@ -3773,6 +3935,7 @@ fn append_control_node_provider_rows(
processed_amount_bits: source_row.max_volume.to_bits(), processed_amount_bits: source_row.max_volume.to_bits(),
current_type1_value_bits: 0x3f80_0000, current_type1_value_bits: 0x3f80_0000,
current_flags: None, current_flags: None,
current_stage: 0,
initial_mesh_state: None, initial_mesh_state: None,
mesh_state: None, mesh_state: None,
}); });
@@ -4344,6 +4507,7 @@ mod tests {
processed_amount_bits: 100.0_f32.to_bits(), processed_amount_bits: 100.0_f32.to_bits(),
current_type1_value_bits: initial_type1_value_bits, current_type1_value_bits: initial_type1_value_bits,
current_flags: None, current_flags: None,
current_stage: 0,
initial_mesh_state: None, initial_mesh_state: None,
mesh_state: None, mesh_state: None,
} }
@@ -5110,6 +5274,11 @@ mod tests {
Err(ControlResourceAccessError::MissionUnavailable) Err(ControlResourceAccessError::MissionUnavailable)
); );
assert_eq!(
update_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),
slot: handle.slot, 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"); 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!( 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),
Err(ControlResourceAccessError::World( Err(ControlResourceAccessError::World(
@@ -5348,6 +5531,171 @@ mod tests {
assert_eq!(table.node_provider_amount_bits, total); 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] #[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");
@@ -6858,6 +7206,48 @@ mod tests {
visible_stage: 0, 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] #[test]
@@ -1013,7 +1013,7 @@ internal static class BehaviorDistributorTickOracle {
Marshal.GetDelegateForFunctionPointer(IntPtr.Add(aniMeshModule, 0x05500), Marshal.GetDelegateForFunctionPointer(IntPtr.Add(aniMeshModule, 0x05500),
typeof(NativeAniMeshNodeLifecycleCallback)); typeof(NativeAniMeshNodeLifecycleCallback));
using (Fixture fixture = new Fixture()) { using (Fixture fixture = new Fixture()) {
const int rowCount = 9; const int rowCount = 11;
IntPtr owner = fixture.Allocate(0x664); IntPtr owner = fixture.Allocate(0x664);
IntPtr rows = fixture.Allocate(rowCount * 0x2c); IntPtr rows = fixture.Allocate(rowCount * 0x2c);
IntPtr aniInterface = fixture.Allocate(0x1b0); 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, 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, 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, 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(); aniMeshStageTransitions.Clear();
aniMeshNodeLifecycleCallbacks.Clear(); aniMeshNodeLifecycleCallbacks.Clear();
@@ -1057,10 +1059,10 @@ internal static class BehaviorDistributorTickOracle {
updateStage(owner, 3, 0); updateStage(owner, 3, 0);
for (int index = 4; index < rowCount; index++) updateStage(owner, index, 0); for (int index = 4; index < rowCount; index++) updateStage(owner, index, 0);
uint[] expectedStages = { 3, 3, 2, 3, 2, 1, 1, 0, 3 }; uint[] expectedStages = { 3, 3, 2, 3, 2, 1, 1, 0, 3, 1, 4194304 };
uint[] expectedVisibleStages = { 3, 3, 1, 3, 2, 1, 1, 0, 2 }; uint[] expectedVisibleStages = { 3, 3, 1, 3, 2, 1, 1, 0, 2, 1, 4194304 };
uint[] expectedAmounts = { 0x42200000, 0x41400000, 0x40e00000, 0x40400000, 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++) { for (int index = 0; index < rowCount; index++) {
IntPtr row = IntPtr.Add(rows, index * 0x2c); IntPtr row = IntPtr.Add(rows, index * 0x2c);
IntPtr meshRow = IntPtr.Add(meshRows, index * 0x130); 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"); "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, 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"); "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 && String.Join(",", aniMeshNodeLifecycleCallbacks.ToArray()) == "7:101:4" && !aniMeshCallbackOwnerMismatch &&
!controlNodeUpdateOwnerMismatch && !nativeAniMeshStageCallbackMissing, !controlNodeUpdateOwnerMismatch && !nativeAniMeshStageCallbackMissing,
"Control stage and slot-0x20 callbacks reach the original AniMesh RVA 0x5810 in direct update order"); "Control stage and slot-0x20 callbacks reach the original AniMesh RVA 0x5810 in direct update order");
Expect(String.Join(",", controlProviderCallbackTrace.ToArray()) == 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"); "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 + 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]" + " 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; activeAniMeshInterface = IntPtr.Zero;
activeNativeAniMeshStageTransition = null; activeNativeAniMeshStageTransition = null;
activeNativeAniMeshNodeLifecycle = null; activeNativeAniMeshNodeLifecycle = null;
+8 -2
View File
@@ -347,8 +347,14 @@ MSH-часть проверки подтверждает начальный сн
соответствующего узла модели. Исходный `AniMesh.dll+0x5750` подтверждён в соответствующего узла модели. Исходный `AniMesh.dll+0x5750` подтверждён в
связке с `Control+0x10f30`: меняется только поле узла `+0x124`, а стадия и связке с `Control+0x10f30`: меняется только поле узла `+0x124`, а стадия и
соседние узлы сохраняются. Эта проекция ещё не подключена к GPU/window path соседние узлы сохраняются. Эта проекция ещё не подключена к GPU/window path
и не выполняет пересчёт стадий, видимости, FX или время Control. Поэтому обработанное количество остаётся и сама delta-операция не выполняет пересчёт стадий, видимости, FX или время
прежним. Гибель root/critical узла и неизвестные flags/aggregate дают явную 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` только для Rust query обращается к загруженному объекту и поддерживает `0xe00` только для
+45 -4
View File
@@ -692,9 +692,11 @@ provider остаётся с заданным тестом значением `0
стадийное наследование доли родителя тоже не переписывает `+0x124` модели. стадийное наследование доли родителя тоже не переписывает `+0x124` модели.
Поэтому Rust хранит `mesh_state.type1_value_bits` отдельно от Поэтому Rust хранит `mesh_state.type1_value_bits` отдельно от
`current_type1_value_bits` provider, а `mesh_state.visible_stage` — отдельно от `current_type1_value_bits` provider, а `mesh_state.visible_stage` — отдельно от
будущего Control stage/sentinel. Неизвестный узел даёт ошибку без частичной текущего Control stage/sentinel. Неизвестный узел даёт ошибку без частичной
записи; callback отражён в CPU-проекции, но GPU/window path ещё не подключён. записи; callback отражён в CPU-проекции, но GPU/window path ещё не подключён.
Полный пересчёт стадии `0x11130`/`0x11220`, видимости и FX также не вызывается. Delta API не вызывает стадийный проход. Отдельный single-node API для
`0x11220` описан в разделе о стадиях; обход `0x11130`, видимость и FX остаются
вне этого исполнения.
Истощение корня или критического узла возвращает `UnsupportedNodeLifecycle` Истощение корня или критического узла возвращает `UnsupportedNodeLifecycle`
до записи: состояние владельца `+0x550`, отложенная гибель и сопутствующие до записи: состояние владельца `+0x550`, отложенная гибель и сопутствующие
callbacks ещё не реализованы. Отсутствие MSH flags, provider aggregate, callbacks ещё не реализованы. Отсутствие MSH flags, provider aggregate,
@@ -928,6 +930,42 @@ provider-состояния в реальной миссии. Ограничен
`AniMesh.dll!FUN_10005810` передаётся только видимое значение `AniMesh.dll!FUN_10005810` передаётся только видимое значение
`min(stage, count - 1)`. `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` находит более поздние строки После перехода родительского ряда `FUN_10011130` находит более поздние строки
с его индексом в поле parent. Обход идёт в глубину: сначала ребёнок и всё его с его индексом в поле parent. Обход идёт в глубину: сначала ребёнок и всё его
поддерево, затем следующий ребёнок. Рост стадии или истощение родителя снимает поддерево, затем следующий ребёнок. Рост стадии или истощение родителя снимает
@@ -1061,7 +1099,8 @@ SHA-256 `65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`,
RVA `0x68a0`) читает три DLL scale globals, которые после LoadLibrary в oracle RVA `0x68a0`) читает три DLL scale globals, которые после LoadLibrary в oracle
равны `1.0`. В Rust `initial_amount_bits` сохраняет это общее начальное равны `1.0`. В Rust `initial_amount_bits` сохраняет это общее начальное
количество отдельно от текущего и последнего обработанного. Прямой delta API количество отдельно от текущего и последнего обработанного. Прямой delta API
меняет только текущее количество; стадийный update пока не перенесён. меняет только текущее количество; отдельный стадийный API обновляет
обработанное и текущую стадию в подтверждённой области.
Licensed full-load regression `full_load_builds_effective_control_device_table_for_mine_unit_dat` Licensed full-load regression `full_load_builds_effective_control_device_table_for_mine_unit_dat`
проверяет значения настоящего `min_l_n1.dat`: корневой provider row `0` даёт проверяет значения настоящего `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`, и обычный строки tag-26 устройства вдвое. Rust `0xe00` и `0x1002` дают `0.5`, и обычный
шаг runtime сохраняет эту долю. Это вызов прямого API, а не полный живой шаг runtime сохраняет эту долю. Это вызов прямого API, а не полный живой
Control tick. Проекция поля модели `+0x124` также получает `0.5`, а её видимая Control tick. Проекция поля модели `+0x124` также получает `0.5`, а её видимая
стадия остаётся `0`; GPU/window path этим тестом не исполняется. стадия этого ресурсного узла остаётся `0`. Затем другой, двухстадийный узел
получает долю `0.5` и отдельный stage API переводит его из `0` в `1`.
GPU/window path этим тестом не исполняется.
Native-часть этих ABI проверок воспроизводит Native-часть этих ABI проверок воспроизводит
`behavior_distributor_tick_native_oracle.cs` на синтетически выделенной памяти `behavior_distributor_tick_native_oracle.cs` на синтетически выделенной памяти
+4 -2
View File
@@ -362,8 +362,10 @@ CollObject, Control и PhysicalModel, пропуская отсутствующ
[техническом разборе](../appendices/script-vm.md#доставка-игровых-часов-в-control). [техническом разборе](../appendices/script-vm.md#доставка-игровых-часов-в-control).
Статическая цепочка ещё не доказывает исполнение активного Control update на Статическая цепочка ещё не доказывает исполнение активного Control update на
миссии. Rust сохраняет исходные и текущие provider-поля отдельно. Прямой delta миссии. Rust сохраняет исходные и текущие provider-поля отдельно. Прямой delta
API меняет строку и значение соответствующего CPU-узла модели, но описанный API меняет строку и значение соответствующего CPU-узла модели; отдельный
clock/budget-путь и вывод этого состояния в окно пока не подключены. стадийный API обновляет его видимую стадию и обработанное количество в
подтверждённой области. Описанный clock/budget-путь и вывод этого состояния
в окно пока не подключены.
В текущем runtime `step_headless` и `frame` получают время очереди явным В текущем runtime `step_headless` и `frame` получают время очереди явным
аргументом `u32`. По нему запускается представленный ресурсный планировщик аргументом `u32`. По нему запускается представленный ресурсный планировщик
+25 -3
View File
@@ -915,8 +915,9 @@ initial_mesh_state: Option<MissionControlNodeInitialMeshState>
Внутри снимка хранятся родительский индекс, переведённые флаги Control и число Внутри снимка хранятся родительский индекс, переведённые флаги Control и число
начальных стадий. Узлы корневой модели добавляются все, а присоединённая `EXTO` начальных стадий. Узлы корневой модели добавляются все, а присоединённая `EXTO`
начинает с локального узла `1`. Это описывает исходную настройку: код пока не начинает с локального узла `1`. Это описывает исходную настройку. Отдельный
переключает стадии в ходе игры и не исполняет полный Control tick. API уже пересчитывает стадию одного узла, но полный игровой Control tick
пока не исполняется.
На той же начальной стадии Control вычисляет количество строки по NDP На той же начальной стадии Control вычисляет количество строки по NDP
`max_volume`. Для загрузки Unit DAT проверенные множители равны `1.0`, поэтому `max_volume`. Для загрузки Unit DAT проверенные множители равны `1.0`, поэтому
@@ -964,6 +965,26 @@ Native oracle соединяет исходные `Control+0x10f30` и `AniMesh+
с критическим флагом `1` пока возвращает ошибку без изменения состояния: с критическим флагом `1` пока возвращает ошибку без изменения состояния:
эта ветвь требует полного lifecycle объекта, а не только нового количества. эта ветвь требует полного 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` прямое уменьшение количества строки ресурсного На исходном `min_l_n1.dat` прямое уменьшение количества строки ресурсного
устройства вдвое меняет ответы `0xe00` и Behavior `0x1002` с `1.0` на `0.5`. устройства вдвое меняет ответы `0xe00` и Behavior `0x1002` с `1.0` на `0.5`.
Обычный последующий шаг runtime сохраняет это состояние. Это воспроизводимый Обычный последующий шаг runtime сохраняет это состояние. Это воспроизводимый
@@ -990,7 +1011,8 @@ Control пересчитывает дочерние строки в глубин
`[102, 570]`. В проверенных векторах одинаковые данные при скрытии и повторном `[102, 570]`. В проверенных векторах одинаковые данные при скрытии и повторном
включении восстановили суммы; переходы с разными слотами — нет. Эти проверки включении восстановили суммы; переходы с разными слотами — нет. Эти проверки
исполняют исходные DLL на тестовых строках, но не являются запуском Control на исполняют исходные DLL на тестовых строках, но не являются запуском Control на
полной миссии; Rust пока не выполняет эти переходы стадий и видимости. полной миссии. Rust выполняет отдельный стадийный шаг в подтверждённой области,
но обход потомков, пересчёт видимости и остальные callbacks ещё не подключены.
Прямой путь изменения количества остаётся отдельной операцией. Прямой путь изменения количества остаётся отдельной операцией.
Подробные границы приведены в [техническом приложении](../appendices/script-vm.md#родители-стадии-и-видимость-provider-узлов). Подробные границы приведены в [техническом приложении](../appendices/script-vm.md#родители-стадии-и-видимость-provider-узлов).