aboutsummaryrefslogtreecommitdiff
path: root/crates/fparkan-runtime
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-29 20:20:45 +0300
committerValentin Popov <valentin@popov.link>2026-10-29 20:20:45 +0300
commitcc5c6414ce68e93a9b7f3c95cc07e9120b400fc1 (patch)
tree6932d76d4041901a31415d3412b66271a233f8e9 /crates/fparkan-runtime
parent6c079d9457fddf9927d3e2057463d95c502d4673 (diff)
downloadfparkan-cc5c6414ce68e93a9b7f3c95cc07e9120b400fc1.tar.xz
fparkan-cc5c6414ce68e93a9b7f3c95cc07e9120b400fc1.zip
feat(runtime): project native Control object classes
Diffstat (limited to 'crates/fparkan-runtime')
-rw-r--r--crates/fparkan-runtime/src/lib.rs182
-rw-r--r--crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs27
2 files changed, 206 insertions, 3 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index a90808c..d0a5ff9 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -41,6 +41,11 @@ pub use fparkan_assets::MissionAssets;
const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256;
const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var";
const INITIAL_BEHAVIOR_RNG_STATE: u32 = 1;
+const UNIT_DAT_CONTROL_CLASS_HIGH_BIT: u32 = 0x8000_0000;
+const CONTROL_CLASS_SOURCE_BTLU: u32 = u32::from_le_bytes(*b"BTLU");
+const CONTROL_CLASS_SOURCE_BULL: u32 = u32::from_le_bytes(*b"BULL");
+const CONTROL_CLASS_SOURCE_WPNS: u32 = u32::from_le_bytes(*b"WPNS");
+const CONTROL_CLASS_SOURCE_STAT: u32 = u32::from_le_bytes(*b"STAT");
/// Injectable engine services used by composition roots.
#[derive(Clone, Default)]
@@ -454,6 +459,8 @@ pub struct MissionControlDeviceTableState {
pub handle: ObjectHandle,
/// Complete base projection, or an explicit unsupported boundary.
pub status: MissionControlDeviceTableStatus,
+ /// Native Control object class chosen by the full Unit DAT load route.
+ pub native_control_class: Option<MissionControlClassProjection>,
/// Raw CTLD profile retained by native component ID zero and its per-owner copy.
pub owner_profile: Option<MissionControlOwnerProfile>,
/// Global NDP provider rows used by indexed Control selectors.
@@ -472,6 +479,24 @@ pub struct MissionControlDeviceTableState {
pub current_output_rate_5e0_bits: u32,
}
+/// Provenance for the class passed to Control's constructor.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum MissionControlClassSource {
+ /// The Unit DAT class word's high bit selected the World -> Terrain route.
+ UnitDatHighBit { header_flags: u32 },
+ /// The root prototype NRes registry entry selected the direct AniMesh route.
+ RootPrototypeFourCc { source_type: u32 },
+}
+
+/// Native Control object class selected during full Unit DAT loading.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct MissionControlClassProjection {
+ /// The class argument received by `Control.dll!LoadControlSystem`.
+ pub class_id: u32,
+ /// Source branch and raw value that selected the class.
+ pub source: MissionControlClassSource,
+}
+
/// CTLD owner profile selected by native component ID zero.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionControlOwnerProfile {
@@ -1518,13 +1543,19 @@ fn load_mission_with_options(
.zip(&handles)
.enumerate()
.map(|(object_index, (draft, handle))| {
+ let prototype_span = prototype_graph
+ .root_prototype_request_spans
+ .get(object_index);
+ let native_control_class = native_control_class_for_root(
+ draft.unit_dat_header_flags,
+ prototype_span,
+ &resolved_prototypes,
+ );
let projection = build_control_device_projection(
object_index,
draft,
&mission_assets,
- prototype_graph
- .root_prototype_request_spans
- .get(object_index),
+ prototype_span,
&resolved_prototypes,
);
match projection {
@@ -1532,6 +1563,7 @@ fn load_mission_with_options(
original_id: draft.original_id,
handle: *handle,
status: MissionControlDeviceTableStatus::Available(projection.devices),
+ native_control_class,
owner_profile: projection.owner_profile,
node_provider_maximum_amount_bits: initial_node_provider_maximum_amount(
&projection.node_provider_status,
@@ -1547,6 +1579,7 @@ fn load_mission_with_options(
original_id: draft.original_id,
handle: *handle,
status: MissionControlDeviceTableStatus::Unavailable(reason.clone()),
+ native_control_class,
owner_profile: None,
node_provider_maximum_amount_bits: None,
node_provider_amount_bits: None,
@@ -4996,6 +5029,37 @@ fn native_ex_to_append_key(
Some(raw_key.wrapping_add(first_global_node).wrapping_sub(1))
}
+fn native_control_class_for_root(
+ unit_dat_header_flags: Option<u32>,
+ prototype_span: Option<&std::ops::Range<usize>>,
+ prototypes: &[fparkan_prototype::EffectivePrototype],
+) -> Option<MissionControlClassProjection> {
+ let header_flags = unit_dat_header_flags?;
+ if header_flags & UNIT_DAT_CONTROL_CLASS_HIGH_BIT != 0 {
+ return Some(MissionControlClassProjection {
+ class_id: 3,
+ source: MissionControlClassSource::UnitDatHighBit { header_flags },
+ });
+ }
+
+ let span = prototype_span?;
+ if span.start >= span.end {
+ return None;
+ }
+ let source_type = prototypes.get(span.start)?.source_type?;
+ let class_id = match source_type {
+ CONTROL_CLASS_SOURCE_BTLU => 4,
+ CONTROL_CLASS_SOURCE_BULL => 9,
+ CONTROL_CLASS_SOURCE_WPNS => 2,
+ CONTROL_CLASS_SOURCE_STAT => 10,
+ _ => 0,
+ };
+ Some(MissionControlClassProjection {
+ class_id,
+ source: MissionControlClassSource::RootPrototypeFourCc { source_type },
+ })
+}
+
fn active_unit_component_order(
records: &[UnitComponentRecord],
tree: &[UnitComponentTreeNode],
@@ -6293,6 +6357,29 @@ mod tests {
(draft, assets, prototypes)
}
+ fn synthetic_control_class_fixture(
+ source_types: &[Option<u32>],
+ ) -> (
+ MissionObjectDraft,
+ Vec<fparkan_prototype::EffectivePrototype>,
+ ) {
+ let component_count = source_types.len();
+ let mut child_counts = vec![0; component_count];
+ if let Some(root_child_count) = child_counts.first_mut() {
+ *root_child_count =
+ u32::try_from(component_count.saturating_sub(1)).expect("child count fits u32");
+ }
+ let (draft, _, prototypes) = synthetic_control_fixture(
+ source_types,
+ &vec![1; component_count],
+ &child_counts,
+ &vec![0; component_count],
+ &vec![1; component_count],
+ vec![None; component_count],
+ );
+ (draft, prototypes)
+ }
+
fn synthetic_control_table(
source_types: &[Option<u32>],
kinds: &[u32],
@@ -6419,6 +6506,7 @@ mod tests {
slot: 7,
},
status,
+ native_control_class: None,
owner_profile: None,
node_provider_maximum_amount_bits: initial_node_provider_maximum_amount(
&node_provider_status,
@@ -9022,6 +9110,82 @@ mod tests {
}
#[test]
+ fn native_control_class_highbit_selects_class_three_before_root_fourcc() {
+ let btlu = u32::from_le_bytes(*b"BTLU");
+ let unknown = u32::from_le_bytes(*b"ZZZZ");
+ for root_source_type in [Some(btlu), Some(unknown), None] {
+ let (mut draft, prototypes) =
+ synthetic_control_class_fixture(&[root_source_type, Some(btlu)]);
+ draft.unit_dat_header_flags = Some(0x8000_0004);
+ let span = 0..prototypes.len();
+ assert_eq!(
+ native_control_class_for_root(
+ draft.unit_dat_header_flags,
+ Some(&span),
+ &prototypes
+ ),
+ Some(MissionControlClassProjection {
+ class_id: 3,
+ source: MissionControlClassSource::UnitDatHighBit {
+ header_flags: 0x8000_0004,
+ },
+ }),
+ "Unit DAT high-bit route overrides root source type {root_source_type:?}"
+ );
+ }
+ }
+
+ #[test]
+ fn native_control_class_clear_route_uses_root_registry_fourcc() {
+ let source_types = [
+ u32::from_le_bytes(*b"BTLU"),
+ u32::from_le_bytes(*b"BULL"),
+ u32::from_le_bytes(*b"WPNS"),
+ u32::from_le_bytes(*b"STAT"),
+ u32::from_le_bytes(*b"ZZZZ"),
+ ];
+ let expected_classes = [4, 9, 2, 10, 0];
+ let (draft, prototypes) = synthetic_control_class_fixture(&source_types.map(Some));
+
+ for (index, (source_type, class_id)) in
+ source_types.into_iter().zip(expected_classes).enumerate()
+ {
+ let span = index..index + 1;
+ assert_eq!(
+ native_control_class_for_root(
+ draft.unit_dat_header_flags,
+ Some(&span),
+ &prototypes
+ ),
+ Some(MissionControlClassProjection {
+ class_id,
+ source: MissionControlClassSource::RootPrototypeFourCc { source_type },
+ }),
+ "source type {source_type:08x} comes from the span's root entry"
+ );
+ }
+ }
+
+ #[test]
+ fn native_control_class_requires_root_metadata_and_never_uses_child_fourcc() {
+ let btlu = u32::from_le_bytes(*b"BTLU");
+ let (mut draft, prototypes) = synthetic_control_class_fixture(&[None, Some(btlu)]);
+ let span = 0..prototypes.len();
+
+ assert_eq!(
+ native_control_class_for_root(draft.unit_dat_header_flags, Some(&span), &prototypes),
+ None,
+ "a child entry cannot substitute for missing root registry metadata"
+ );
+ draft.unit_dat_header_flags = None;
+ assert_eq!(
+ native_control_class_for_root(draft.unit_dat_header_flags, Some(&span), &prototypes),
+ None,
+ "missing Unit DAT class word must remain explicit"
+ );
+ }
+
+ #[test]
fn exto_key_mapping_obeys_native_component_and_mount_sentinels() {
assert_eq!(native_ex_to_append_key(2, 0, None, Some(20)), Some(2));
assert_eq!(
@@ -9896,6 +10060,15 @@ mod tests {
let mine_control_before = loaded_mission_control_device_tables(&mine_engine)
.expect("licensed mine Control table")
.to_vec();
+ assert_eq!(
+ mine_control_before[0].native_control_class,
+ Some(MissionControlClassProjection {
+ class_id: 3,
+ source: MissionControlClassSource::UnitDatHighBit {
+ header_flags: 0x8000_0004,
+ },
+ })
+ );
let mine_prepared_controls = &mine_engine
.loaded
.as_ref()
@@ -11820,6 +11993,7 @@ mod tests {
original_id: Some(OriginalObjectId(original_id)),
handle,
status: MissionControlDeviceTableStatus::Available(vec![device]),
+ native_control_class: None,
owner_profile: None,
node_provider_status: MissionControlNodeProviderTableStatus::Available(Vec::new()),
node_provider_maximum_amount_bits: Some(0),
@@ -11929,6 +12103,7 @@ mod tests {
),
ore_device,
]),
+ native_control_class: None,
owner_profile: None,
node_provider_status: MissionControlNodeProviderTableStatus::Available(vec![
synthetic_node_provider_row(0, 0, 1.0_f32.to_bits()),
@@ -11984,6 +12159,7 @@ mod tests {
original_id: Some(object.original_id),
handle: object.handle,
status: MissionControlDeviceTableStatus::Available(Vec::new()),
+ native_control_class: None,
owner_profile: None,
node_provider_status: MissionControlNodeProviderTableStatus::Available(Vec::new()),
node_provider_maximum_amount_bits: Some(0),
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 d92c46a..d72fd57 100644
--- a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs
+++ b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs
@@ -10,6 +10,7 @@ using System.Text;
// slots, and bounded vector backing are substituted; tested module code remains installed.
internal static class BehaviorDistributorTickOracle {
private const uint LoadWithAlteredSearchPath = 0x00000008;
+ private const uint DontResolveDllReferences = 0x00000001;
private const uint MemCommitReserve = 0x00003000;
private const uint PageExecuteReadWrite = 0x40;
private const uint MemRelease = 0x00008000;
@@ -18,7 +19,9 @@ internal static class BehaviorDistributorTickOracle {
private const string ExpectedControlSha256 = "bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e";
private const string ExpectedArealMapSha256 = "a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea";
private const string ExpectedAniMeshSha256 = "65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e";
+ private const string ExpectedTerrainSha256 = "af87d1b2e728a0be73c52be3b44cc196ab46da7799f25a15d40f8c9b0b425ead";
private const int TickRva = 0x19e80;
+ private const int TerrainClassGetterRva = 0x33ae0;
private const int DistributorScheduleRva = 0x1adb0;
private const int AddObjectRva = 0x1aac0;
private const int NormalizeRva = 0x1ac90;
@@ -96,6 +99,7 @@ internal static class BehaviorDistributorTickOracle {
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void X87ControlWordReader();
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void X87ControlWordWriter();
[UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void TickDelegate(IntPtr self, float deltaSeconds);
+ [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate uint TerrainClassGetter(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void NativeBehaviorSchedule(IntPtr self);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate IntPtr QueueGetter();
[UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate uint QueueClockGetter(IntPtr self);
@@ -579,8 +583,31 @@ internal static class BehaviorDistributorTickOracle {
return sequence[Math.Min(index, sequence.Length - 1)];
}
+ private static int ProbeTerrainClass() {
+ string terrainPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_TERRAIN_DLL");
+ if (String.IsNullOrEmpty(terrainPath)) terrainPath = @"C:\GOG Games\Parkan - Iron Strategy\Terrain.dll";
+ if (!File.Exists(terrainPath)) { Console.Error.WriteLine("Set FPARKAN_ORIGINAL_TERRAIN_DLL to the installed Terrain.dll."); return 3; }
+ string hash = Hash(terrainPath);
+ if (hash != ExpectedTerrainSha256) { Console.Error.WriteLine("Unexpected Terrain.dll SHA-256: " + hash); return 4; }
+ IntPtr module = LoadLibraryEx(terrainPath, IntPtr.Zero, DontResolveDllReferences);
+ if (module == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx(Terrain.dll) failed: " + Marshal.GetLastWin32Error()); return 5; }
+ try {
+ IntPtr getterAddress = IntPtr.Add(module, TerrainClassGetterRva);
+ byte[] getterBytes = new byte[6]; Marshal.Copy(getterAddress, getterBytes, 0, getterBytes.Length);
+ string code = BitConverter.ToString(getterBytes).Replace("-", "");
+ if (code != "B803000000C3") { Console.Error.WriteLine("Unexpected Terrain getter bytes: " + code); return 6; }
+ TerrainClassGetter getter = (TerrainClassGetter)Marshal.GetDelegateForFunctionPointer(getterAddress, typeof(TerrainClassGetter));
+ uint classId = getter(IntPtr.Zero);
+ if (classId != 3) { Console.Error.WriteLine("Unexpected Terrain class ID: " + classId); return 7; }
+ Console.WriteLine("PASS sha256=" + hash + " rva=0x33AE0 bytes=" + code + " this=null class=3");
+ return 0;
+ } finally { FreeLibrary(module); }
+ }
+
private static int Main() {
if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; }
+ int terrainClass = ProbeTerrainClass();
+ if (terrainClass != 0) return terrainClass;
return RunWithIsolatedWorkingDirectory();
}