From ccb5af0e37b94942962004ea2e84eea1d519c4bb Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 22 Oct 2026 12:20:42 +0400 Subject: feat(assets): sample native resource device coefficient --- crates/fparkan-assets/src/lib.rs | 332 +++++++++++++++++++++ .../behavior_distributor_tick_native_oracle.cs | 96 +++++- docs/appendices/knowledge-boundaries.md | 29 +- docs/tomes/06-behavior.md | 60 ++++ 4 files changed, 509 insertions(+), 8 deletions(-) diff --git a/crates/fparkan-assets/src/lib.rs b/crates/fparkan-assets/src/lib.rs index d34effb..da3411e 100644 --- a/crates/fparkan-assets/src/lib.rs +++ b/crates/fparkan-assets/src/lib.rs @@ -1180,6 +1180,19 @@ impl Default for ControlTag21InstanceState { /// Input or metadata prevented a safe native control sample. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ControlSamplingError { + /// This resource-device helper only implements typed-record tag 26. + UnsupportedResourceDeviceTag { actual: u32 }, + /// The record's raw provider key has no resolved NDP table. + MissingResourceDeviceNodeData { key: u32 }, + /// The record's raw provider key is not a row in its NDP table. + ResourceDeviceNodeDataKeyOutOfRange { key: u32, row_count: usize }, + /// The caller did not supply the provider's current type-1 value. + MissingResourceDeviceProviderValue { key: u32 }, + /// A coefficient is outside the helper's finite normal-or-zero contract. + InvalidResourceDeviceOperand { operand: &'static str }, + /// The native x87 intermediate is outside the range represented by normal + /// f32 values, so the Rust result could differ from the original. + UnsupportedResourceDeviceFloatRange { stage: &'static str }, /// A state object was reused with a different model node count. StateNodeCountMismatch { expected: usize, actual: usize }, /// A state object was reused with a different number of tag-21 records. @@ -1216,6 +1229,74 @@ pub enum ControlSamplingError { NonFiniteSample { record_index: usize }, } +/// Samples Control's tag-26 typed-record query `0x300` for one current state. +/// +/// The key in fixed word 1 must select an existing NDP row. The caller supplies +/// that provider's current type-1 value and the object's current multiplier; +/// this function does not invent their runtime state. Fixed word 11 is the +/// coefficient loaded by selector `0x300`. Native code multiplies in the order +/// `provider_now * coefficient * multiplier_now`. +/// +/// The source x87 code returns a value with an extended exponent range even +/// though it uses 24-bit precision. This f32 helper therefore accepts only +/// finite normal-or-zero operands and normal-or-exact-zero intermediate and +/// final products. It rejects subnormal or out-of-f32-range products rather +/// than silently underflowing or overflowing differently from x87. +pub fn sample_control_resource_device_selector_300( + record: &PreparedControlTypedRecord, + node_data: Option<&PreparedControlNodeDataTable>, + provider_now: Option, + multiplier_now: f32, +) -> Result { + if record.tag != 26 { + return Err(ControlSamplingError::UnsupportedResourceDeviceTag { actual: record.tag }); + } + + let key = record.fixed_words[1]; + let Some(node_data) = node_data else { + return Err(ControlSamplingError::MissingResourceDeviceNodeData { key }); + }; + if !usize::try_from(key).is_ok_and(|key| key < node_data.rows.len()) { + return Err(ControlSamplingError::ResourceDeviceNodeDataKeyOutOfRange { + key, + row_count: node_data.rows.len(), + }); + } + let provider_now = + provider_now.ok_or(ControlSamplingError::MissingResourceDeviceProviderValue { key })?; + let base = f32::from_bits(record.fixed_words[11]); + + for (operand, value) in [ + ("provider", provider_now), + ("base coefficient", base), + ("multiplier", multiplier_now), + ] { + if !value.is_finite() || (value != 0.0 && !value.is_normal()) { + return Err(ControlSamplingError::InvalidResourceDeviceOperand { operand }); + } + } + + let provider_times_base = + native_range_compatible_product(provider_now, base, "provider times base")?; + native_range_compatible_product(provider_times_base, multiplier_now, "then multiplier") +} + +fn native_range_compatible_product( + lhs: f32, + rhs: f32, + stage: &'static str, +) -> Result { + let exact = f64::from(lhs) * f64::from(rhs); + let absolute = exact.abs(); + let minimum_normal = f64::from(f32::MIN_POSITIVE); + let maximum = f64::from(f32::MAX); + if exact != 0.0 && (absolute < minimum_normal || absolute > maximum) { + return Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange { stage }); + } + + Ok(lhs * rhs) +} + /// Samples per-node animation times using the decoded Control timeline. /// /// `native_clock_ms` is the absolute u32 millisecond clock passed to native @@ -4141,6 +4222,203 @@ mod tests { // Preserve the authored CTLD fixture bits; this is not the `f32` value of tau. const AUTHORED_TAG21_RAW_PARAMETER_3: f32 = f32::from_bits(0x40C8_F5C3); + fn resource_device_record(tag: u32, key: u32, base_bits: u32) -> PreparedControlTypedRecord { + let mut fixed_words = [0; 44]; + fixed_words[1] = key; + fixed_words[11] = base_bits; + PreparedControlTypedRecord { + tag, + fixed_words, + values: Vec::new(), + string_bytes: Vec::new(), + gate_provider: PreparedControlGateProvider::NotApplicable, + } + } + + fn resource_device_node_data_table(row_count: usize) -> PreparedControlNodeDataTable { + PreparedControlNodeDataTable { + source: ResourceKey { + archive: parse_path("parts.rlb").expect("NDP archive"), + name: resource_name(b"resource-device.ndp"), + type_id: None, + }, + rows: vec![ + PreparedControlNodeDataRow { + raw_flags: 0, + max_volume: 0.0, + mobility_per_volume: 0.0, + effect: PreparedControlResourcePair { + archive: [0; 32], + name: [0; 32], + }, + }; + row_count + ], + } + } + + #[test] + fn tag26_selector_300_multiplies_provider_then_fixed_base_then_multiplier() { + let record = resource_device_record(26, 1, 0x3D25_AE4A); + let node_data = resource_device_node_data_table(2); + let provider_now = f32::from_bits(0x3FE6_982F); + let multiplier_now = f32::from_bits(0x4071_5539); + + let sampled = sample_control_resource_device_selector_300( + &record, + Some(&node_data), + Some(provider_now), + multiplier_now, + ) + .expect("valid tag-26 resource-device sample"); + + assert_eq!(sampled.to_bits(), 0x3E8C_B02E); + assert_ne!( + sampled.to_bits(), + ((provider_now * multiplier_now) * f32::from_bits(record.fixed_words[11])).to_bits(), + "the original selector multiplies the provider by the record base first" + ); + } + + #[test] + fn tag26_selector_300_requires_its_ndp_row_and_explicit_provider() { + let record = resource_device_record(26, 1, 2.0_f32.to_bits()); + let one_row = resource_device_node_data_table(1); + assert_eq!( + sample_control_resource_device_selector_300(&record, Some(&one_row), Some(3.0), 4.0,), + Err(ControlSamplingError::ResourceDeviceNodeDataKeyOutOfRange { + key: 1, + row_count: 1, + }) + ); + + let two_rows = resource_device_node_data_table(2); + assert_eq!( + sample_control_resource_device_selector_300(&record, Some(&two_rows), None, 4.0), + Err(ControlSamplingError::MissingResourceDeviceProviderValue { key: 1 }) + ); + assert_eq!( + sample_control_resource_device_selector_300(&record, None, Some(3.0), 4.0), + Err(ControlSamplingError::MissingResourceDeviceNodeData { key: 1 }) + ); + } + + #[test] + fn tag26_selector_300_rejects_other_tags_and_extended_x87_ranges() { + let node_data = resource_device_node_data_table(1); + let tag21 = resource_device_record(21, 0, 1.0_f32.to_bits()); + assert_eq!( + sample_control_resource_device_selector_300(&tag21, Some(&node_data), Some(1.0), 1.0), + Err(ControlSamplingError::UnsupportedResourceDeviceTag { actual: 21 }) + ); + + let tag26 = resource_device_record(26, 0, f32::MIN_POSITIVE.to_bits()); + assert_eq!( + sample_control_resource_device_selector_300( + &tag26, + Some(&node_data), + Some(f32::MIN_POSITIVE), + 1.0, + ), + Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange { + stage: "provider times base", + }) + ); + } + + #[test] + fn tag26_selector_300_preserves_negative_and_signed_zero_values() { + let node_data = resource_device_node_data_table(1); + let record = resource_device_record(26, 0, 0.5_f32.to_bits()); + let negative = + sample_control_resource_device_selector_300(&record, Some(&node_data), Some(-2.0), 4.0) + .expect("negative factors are not clamped"); + assert_eq!(negative, -4.0); + + let negative_zero = sample_control_resource_device_selector_300( + &resource_device_record(26, 0, 2.0_f32.to_bits()), + Some(&node_data), + Some(-0.0), + 3.0, + ) + .expect("signed zero is an exact native input"); + assert_eq!(negative_zero.to_bits(), (-0.0_f32).to_bits()); + + let positive_zero = sample_control_resource_device_selector_300( + &resource_device_record(26, 0, 2.0_f32.to_bits()), + Some(&node_data), + Some(0.0), + 3.0, + ) + .expect("positive zero is an exact native input"); + assert_eq!(positive_zero.to_bits(), 0.0_f32.to_bits()); + } + + #[test] + fn tag26_selector_300_rejects_nonfinite_and_subnormal_operands() { + let node_data = resource_device_node_data_table(1); + for (operand, provider_now, multiplier_now, base_bits) in [ + ("provider", f32::NAN, 1.0, 1.0_f32.to_bits()), + ("multiplier", 1.0, f32::INFINITY, 1.0_f32.to_bits()), + ("base coefficient", 1.0, 1.0, 1), + ] { + let record = resource_device_record(26, 0, base_bits); + assert_eq!( + sample_control_resource_device_selector_300( + &record, + Some(&node_data), + Some(provider_now), + multiplier_now, + ), + Err(ControlSamplingError::InvalidResourceDeviceOperand { operand }) + ); + } + } + + #[test] + fn tag26_selector_300_rejects_extended_intermediate_and_final_ranges() { + let node_data = resource_device_node_data_table(1); + let provider = f32::MAX; + let overflowing_intermediate = resource_device_record(26, 0, 2.0_f32.to_bits()); + assert_eq!( + sample_control_resource_device_selector_300( + &overflowing_intermediate, + Some(&node_data), + Some(provider), + 0.25, + ), + Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange { + stage: "provider times base", + }) + ); + + let underflowing_intermediate = resource_device_record(26, 0, f32::MIN_POSITIVE.to_bits()); + assert_eq!( + sample_control_resource_device_selector_300( + &underflowing_intermediate, + Some(&node_data), + Some(f32::MIN_POSITIVE), + f32::MAX, + ), + Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange { + stage: "provider times base", + }) + ); + + let subnormal_final = resource_device_record(26, 0, 1.0_f32.to_bits()); + assert_eq!( + sample_control_resource_device_selector_300( + &subnormal_final, + Some(&node_data), + Some(f32::MIN_POSITIVE), + 0.5, + ), + Err(ControlSamplingError::UnsupportedResourceDeviceFloatRange { + stage: "then multiplier", + }) + ); + } + fn tag21_fullcycle_control() -> PreparedComponentControl { let source = ResourceKey { archive: parse_path("parts.rlb").expect("archive"), @@ -6164,6 +6442,60 @@ mod tests { assert_eq!(plan.lightmap_count, 0); } + #[test] + #[ignore = "requires licensed corpus"] + fn tag26_selector_300_uses_a_real_control_record_and_ndp_key() { + let root = fixture_root("IS"); + let vfs: Arc = Arc::new(DirectoryVfs::new(&root)); + let repository = CachedResourceRepository::new(Arc::clone(&vfs)); + let roots = [resource_name(b"UNITS/BUILDS/MINE/min_l_n1.dat")]; + let (_graph, prototypes) = + build_prototype_graph(&repository, vfs.as_ref(), &roots).expect("prototype graph"); + let assets = prepare_mission_assets_with_repository( + &repository, + &[0..prototypes.len()], + &prototypes, + ) + .expect("prepared licensed unit controls"); + + let mut sampled_records = 0; + for control in assets.object_component_controls.iter().flatten().flatten() { + for record in control + .typed_records + .iter() + .filter(|record| record.tag == 26) + { + let node_data = control + .node_data + .as_ref() + .expect("tag-26 control has its explicit NDP table"); + assert_eq!(control.source.name.0, b"fr_l_mine.ctl"); + assert_eq!(node_data.source.name.0, b"fr_l_mine.ndp"); + assert_eq!(node_data.rows.len(), 22); + assert_eq!(record.fixed_words[1], 2); + assert_eq!(record.fixed_words[11], 0x3F80_0000); + let sample = sample_control_resource_device_selector_300( + record, + Some(node_data), + Some(1.0), + 1.0, + ) + .expect("real tag-26 key selects an NDP row"); + assert_eq!(sample.to_bits(), record.fixed_words[11]); + println!( + "control={}: ndp={}: key={} rows={} coefficient=0x{:08x}", + String::from_utf8_lossy(&control.source.name.0), + String::from_utf8_lossy(&node_data.source.name.0), + record.fixed_words[1], + node_data.rows.len(), + record.fixed_words[11], + ); + sampled_records += 1; + } + } + assert_eq!(sampled_records, 1); + } + #[test] #[ignore = "requires licensed corpus"] fn repository_plan_deduplicates_duplicate_visuals_but_graph_preserves_requests() { 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 8532d1a..d2e5bb9 100644 --- a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs +++ b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs @@ -16,6 +16,7 @@ internal static class BehaviorDistributorTickOracle { private const uint MemRelease = 0x00008000; private const uint PageReadWrite = 0x04; private const string ExpectedBehaviorSha256 = "ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99"; + private const string ExpectedControlSha256 = "bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e"; private const int TickRva = 0x19e80; private const int AddObjectRva = 0x1aac0; private const int NormalizeRva = 0x1ac90; @@ -23,6 +24,9 @@ internal static class BehaviorDistributorTickOracle { private const int IndexedPropertyGetRva = 0x0b5b0; private const int OreInitializationRva = 0x08790; private const int GetSystemArealMapIatRva = 0x59008; + private const int ControlResourceDeviceGetterRva = 0x21d00; + private const int ControlProviderInterfaceVtableRva = 0x3d254; + private const int ControlProviderType1GetterRva = 0x0dc40; private const uint ResourceProperty = 0x02000100; private static Fixture active; @@ -52,6 +56,7 @@ internal static class BehaviorDistributorTickOracle { [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void IndexedPropertySet(IntPtr self, uint index, uint valueBits); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int IndexedPropertyGet(IntPtr self, uint index, IntPtr resultBits); [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeOreInitialization(IntPtr self); + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate float NativeControlResourceDeviceGet(IntPtr self, uint selector); private static readonly AreaMapGetter AreaMapGetterCallback = GetAreaMap; private static readonly VectorInsert VectorInsertCallback = InsertVector; @@ -306,13 +311,20 @@ internal static class BehaviorDistributorTickOracle { private static int Main() { if (IntPtr.Size != 4) { Console.Error.WriteLine("Build with /platform:x86."); return 2; } + string controlPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_CONTROL_DLL"); + if (String.IsNullOrEmpty(controlPath)) controlPath = @"C:\GOG Games\Parkan - Iron Strategy\Control.dll"; + if (!File.Exists(controlPath)) { Console.Error.WriteLine("Set FPARKAN_ORIGINAL_CONTROL_DLL to the installed Control.dll."); return 7; } + string controlHash = Hash(controlPath); + if (controlHash != ExpectedControlSha256) { Console.Error.WriteLine("Unexpected Control.dll SHA-256: " + controlHash); return 8; } + IntPtr controlModule = LoadLibraryEx(controlPath, IntPtr.Zero, LoadWithAlteredSearchPath); + if (controlModule == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx(Control.dll) failed: " + Marshal.GetLastWin32Error()); return 9; } string path = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_BEHAVIOR_DLL"); if (String.IsNullOrEmpty(path)) path = @"C:\GOG Games\Parkan - Iron Strategy\Behavior.dll"; - if (!File.Exists(path)) { Console.Error.WriteLine("Set FPARKAN_ORIGINAL_BEHAVIOR_DLL to the installed Behavior.dll."); return 3; } + if (!File.Exists(path)) { Console.Error.WriteLine("Set FPARKAN_ORIGINAL_BEHAVIOR_DLL to the installed Behavior.dll."); FreeLibrary(controlModule); return 3; } string hash = Hash(path); - if (hash != ExpectedBehaviorSha256) { Console.Error.WriteLine("Unexpected Behavior.dll SHA-256: " + hash); return 4; } + if (hash != ExpectedBehaviorSha256) { Console.Error.WriteLine("Unexpected Behavior.dll SHA-256: " + hash); FreeLibrary(controlModule); return 4; } IntPtr module = LoadLibraryEx(path, IntPtr.Zero, LoadWithAlteredSearchPath); - if (module == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx(Behavior.dll) failed: " + Marshal.GetLastWin32Error()); return 5; } + if (module == IntPtr.Zero) { Console.Error.WriteLine("LoadLibraryEx(Behavior.dll) failed: " + Marshal.GetLastWin32Error()); FreeLibrary(controlModule); return 5; } IntPtr import = IntPtr.Add(module, GetSystemArealMapIatRva); IntPtr originalImport = Marshal.ReadIntPtr(import); uint oldProtection = 0; @@ -334,7 +346,7 @@ internal static class BehaviorDistributorTickOracle { throw new InvalidOperationException("VirtualProtect(GetSystemArealMap IAT) failed: " + Marshal.GetLastWin32Error()); protectionChanged = true; Marshal.WriteIntPtr(import, Marshal.GetFunctionPointerForDelegate(AreaMapGetterCallback)); - RunSmokeCases(module); + RunSmokeCases(module, controlModule); Console.WriteLine("original Behavior.dll distributor oracle passed (native Add 0x1aac0, normalize 0x1ac90, tick 0x19e80; only GetSystemArealMap IAT and bounded vector insertion are substituted; this does not compare Rust parity)."); return 0; } @@ -357,10 +369,11 @@ internal static class BehaviorDistributorTickOracle { throw new InvalidOperationException("VirtualFree(fastcall callback thunks) failed: " + Marshal.GetLastWin32Error()); if (x87ControlWordCell != IntPtr.Zero) { Marshal.FreeHGlobal(x87ControlWordCell); x87ControlWordCell = IntPtr.Zero; } FreeLibrary(module); + FreeLibrary(controlModule); } } - private static void RunSmokeCases(IntPtr module) { + private static void RunSmokeCases(IntPtr module, IntPtr controlModule) { ushort savedControlWord = CurrentX87ControlWord(); Console.WriteLine("x87-control-word-before=0x" + savedControlWord.ToString("x4") + " (x86 oracle process)"); try { @@ -368,6 +381,7 @@ internal static class BehaviorDistributorTickOracle { Console.WriteLine("native-add-normalize-x87-control-word=0x" + CurrentX87ControlWord().ToString("x4")); RunNativeAddCases(module); RunNativeNormalizeCases(module); + RunNativeControlResourceDeviceCase(controlModule); } finally { SetX87ControlWord(savedControlWord); Console.WriteLine("x87-control-word-restored=0x" + CurrentX87ControlWord().ToString("x4")); @@ -396,6 +410,76 @@ internal static class BehaviorDistributorTickOracle { RunSkippedCase(tick); } + private static void RunNativeControlResourceDeviceCase(IntPtr module) { + Expect(CurrentX87ControlWord() == 0x007f, "Control selector 0x300 uses x87 control word 0x007f"); + const uint key = 2; + const int rowCount = 22; + const int providerRow = (int)key * 0x2c; + IntPtr providerOwner = Marshal.AllocHGlobal(0x560); + IntPtr providerRows = Marshal.AllocHGlobal(rowCount * 0x2c); + IntPtr device = IntPtr.Zero; + try { + Zero(providerOwner, 0x560); + Zero(providerRows, rowCount * 0x2c); + IntPtr providerInterface = IntPtr.Add(providerOwner, 4); + IntPtr providerVtable = IntPtr.Add(module, ControlProviderInterfaceVtableRva); + Marshal.WriteIntPtr(providerInterface, providerVtable); + Expect( + Marshal.ReadIntPtr(providerVtable, 0x0c) == IntPtr.Add(module, ControlProviderType1GetterRva), + "Control provider interface vtable uses the native type-1 getter"); + Marshal.WriteIntPtr(providerOwner, 0x55c, providerRows); + + device = Marshal.AllocHGlobal(0x94); + Zero(device, 0x94); + Marshal.WriteInt32(device, 0x04, unchecked((int)key)); + Marshal.WriteIntPtr(device, 0x44, providerInterface); + + NativeControlResourceDeviceGet getter = (NativeControlResourceDeviceGet) + Marshal.GetDelegateForFunctionPointer( + IntPtr.Add(module, ControlResourceDeviceGetterRva), + typeof(NativeControlResourceDeviceGet)); + ExpectControlResourceDeviceResult(getter, device, providerRows, providerRow, + 0x3fe6982f, 0x3d25ae4a, 0x40715539, 0x3e8cb02e, "order-sensitive normal"); + ExpectControlResourceDeviceResult(getter, device, providerRows, providerRow, + 0x7f7fffff, 0x40000000, 0x3e800000, 0x7effffff, "extended intermediate overflow then finite"); + ExpectControlResourceDeviceResult(getter, device, providerRows, providerRow, + 0x00800000, 0x00800000, 0x7f7fffff, 0x017fffff, "extended intermediate underflow then finite"); + ExpectControlResourceDeviceResult(getter, device, providerRows, providerRow, + 0x00800000, 0x3f800000, 0x3f000000, 0x00400000, "subnormal final result"); + ExpectControlResourceDeviceResult(getter, device, providerRows, providerRow, + 0xc0000000, 0x3f000000, 0x40800000, 0xc0800000, "negative factors"); + ExpectControlResourceDeviceResult(getter, device, providerRows, providerRow, + 0x80000000, 0x40000000, 0x40400000, 0x80000000, "negative zero"); + Expect(CurrentX87ControlWord() == 0x007f, "Control selector 0x300 retains x87 control word 0x007f"); + Console.WriteLine("native-control-tag26-selector-300: controlHash=" + ExpectedControlSha256 + + " cw=0x" + CurrentX87ControlWord().ToString("x4") + " key=2 rows=22"); + GC.KeepAlive(getter); + } finally { + if (device != IntPtr.Zero) Marshal.FreeHGlobal(device); + Marshal.FreeHGlobal(providerRows); + Marshal.FreeHGlobal(providerOwner); + } + } + + private static void ExpectControlResourceDeviceResult( + NativeControlResourceDeviceGet getter, + IntPtr device, + IntPtr providerRows, + int providerRow, + uint providerBits, + uint baseBits, + uint multiplierBits, + uint expectedBits, + string label) { + Marshal.WriteInt32(providerRows, providerRow + 0x14, unchecked((int)providerBits)); + Marshal.WriteInt32(device, 0x54, unchecked((int)baseBits)); + Marshal.WriteInt32(device, 0x4c, unchecked((int)multiplierBits)); + uint actualBits = unchecked((uint)FloatBits(getter(device, 0x300))); + Expect(actualBits == expectedBits, + "Control selector 0x300 " + label + " expected " + Hex(expectedBits) + " but got " + Hex(actualBits)); + Console.WriteLine("native-control-tag26-selector-300-" + label + ": result=" + Hex(actualBits)); + } + private static void RunNativeIndexedPropertyCases(IntPtr module) { IndexedPropertySet set = (IndexedPropertySet)Marshal.GetDelegateForFunctionPointer( IntPtr.Add(module, IndexedPropertySetRva), typeof(IndexedPropertySet)); @@ -911,7 +995,7 @@ internal static class BehaviorDistributorTickOracle { foreach (byte value in digest) hex.Append(value.ToString("x2")); return hex.ToString(); } } - private static void Expect(bool value, string message) { if (!value) throw new InvalidOperationException("original Behavior oracle failed: " + message); } + private static void Expect(bool value, string message) { if (!value) throw new InvalidOperationException("original native oracle failed: " + message); } private static void ExpectLookupOrder(Fixture fixture, params string[] keys) { List actual = new List(); foreach (string item in fixture.Trace) if (item.StartsWith("lookup:", StringComparison.Ordinal)) actual.Add(item.Substring(7)); diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md index c1993ca..9db3f49 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -130,7 +130,7 @@ host construction phases. Не проверена другая лицензир непустым `Init`. Вызов `Behavior.dll!FUN_10019e80` (RVA `0x19e80`, SHA-256 `ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) исполнялся на синтетических интерфейсах. Путь членства в реальных distributor-списках -теперь связан с полями TMA и Unit DAT: `World3D!CreateObject` передаёт selector +связан с полями TMA и Unit DAT: `World3D!CreateObject` передаёт selector и class word через дескриптор в Behavior (RVA `0x7e55`, `0x7e62`), а `Behavior.dll!FUN_10005e60` (RVA `0x5e60`) проверяет высокий бит registration key, AI и distributor. Selector из первого DWORD после имени ресурса выбирает @@ -203,7 +203,15 @@ prototype roots без Unit DAT и отложенные spawn после исх этой модели. Путь в `ArealMap.dll` (SHA-256 `a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`) к `CreateObject` по RVA `0x14fd0`/`0x15290` показывает выбор World3D class 3 или 4 -по старшему биту, но не всю цепочку их Behavior attachment. +по старшему биту. Для ветки GOG `AniMesh.dll` (SHA-256 +`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`) RVA +`0x2ff0` показывает одну ветку создания: после QueryInterface для IID `0x204` +выполняется отдельный импорт `GetQueue()`, а на возвращённом объекте вызывается +метод через vtable slot `+0x30`. `CreateBehaviour` вызывается только при +ненулевом результате этого метода и host class `3` или `4`; class `4` получает +адреса полей prototype `+0x140` и `+0x160`. Семантика метода slot `+0x30` не +установлена. Другие factory paths, нулевой результат и spawn после исходной +загрузки остаются за границами подтверждённой ветки. Для `Handler(30)` отдельно подтверждены обратный вызов `iron3d.dll` RVA `0x611d0` (SHA-256 `d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`), @@ -239,6 +247,23 @@ CTLD и связанные resources структурно читаются, coun известны. Не названы все секции, shape types, coefficients и точный contact solver. То же относится к редким MSH auxiliary streams и части CTPT/NDPR flags. +Для `Control.dll` (SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`) tag-26 +getter selector `0x300` восстановлен локально: RVA `0x21d00` читает provider по +ключу `fixed_words[1]`, затем умножает provider, CTLD coefficient `fixed_words[11]` +и поле объекта `+0x4c` именно в таком порядке. Rust helper принимает текущие +provider и multiplier извне; он не восстанавливает их жизненный цикл или весь +Control tick. В x86 oracle при x87 control word `0x007f` проверены порядок, +signed zero, отрицательные входы, расширенный промежуточный диапазон и +subnormal return. Helper ограничивает себя normal-or-zero значениями, поэтому +явно отклоняет промежуточные случаи, где результат выходит за поддерживаемый +диапазон f32, даже если позднее умножение вернуло бы его в диапазон. Это +ограничение поддержки Rust helper-а, а не неверный native-результат. + +В Unit DAT fixture `UNITS/BUILDS/MINE/min_l_n1.dat` проверена реальная связка +`fr_l_mine.ctl` + `fr_l_mine.ndp`: tag `26`, ключ `2` в таблице из `22` строк, +коэффициент `1.0`. Это не запись живого resource-device значения в миссии. + Закрывающий эксперимент: трассировать `LoadControlSystem`, `LoadPhysicalModel`, `CreateCollManager` и создание collision objects; связать каждый изменяемый field с созданным shape, contact или реакцией на движение. diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index 16ea21a..125f0d5 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -547,6 +547,66 @@ Control consumer-у: никакой компонент пока не получ по `kind`, `parent_or_link` или description; semantic binding появится лишь после trace private update/load methods. +Отдельная ветка того же GOG `AniMesh.dll` показывает, когда создаётся Behavior +для host object. В `FUN_10002ff0` (RVA `0x2ff0`, SHA-256 +`65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e`) вызывающая +сторона запрашивает IID `0x204` и сохраняет интерфейс в поле `+0x168`. Затем +отдельный импорт `GetQueue()` возвращает объект очереди; вызов метода через его +vtable slot `+0x30` даёт результат, который проверяется на ненулевое значение. +Только при ненулевом результате host class `3` вызывает +`CreateBehaviour(0, 0, self)` (RVA `0x34bd`); class `4` передаёт в +`CreateBehaviour` адреса полей prototype `+0x140` и `+0x160` (RVA `0x3426`). +Семантика метода slot `+0x30` здесь не установлена. Это подтверждает только +такую ветку и эти два класса; другие фабрики, нулевой результат и отложенные +spawn сюда не входят. + +### Один запрос Control для ресурсного устройства + +В CTLD запись tag `26` задаёт ресурсный множитель, но текущее значение +поставщика живёт в runtime state Control. GOG `Control.dll` (SHA-256 +`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`) +`FUN_10021d00` (RVA `0x21d00`) реализует getter selector `0x300`. Его фиксированное +слово `+0x2c` становится `fixed_words[11]` при копировании записи RVA `0x21d50`; +`fixed_words[1]` — ключ строки NDP. В объекте Control getter использует ключ +`+0x04`, provider interface `+0x44`, множитель `+0x4c` и коэффициент `+0x54`. +Provider interface направляет selector `1` и ключ в Control RVA `0xdc40`, где +читается текущее type-1 значение выбранной строки. Затем selector умножает +сначала provider на коэффициент, после чего на множитель: `provider * base * +multiplier`. Коэффициент приходит из CTLD; NDP связывает ключ с описанной +строкой, а текущее значение provider нельзя заменить предположением о состоянии +мира. + +Так этот селектор включён в цепочку добычи руды оригинала. В той же сборке +`Behavior.dll` (SHA-256 +`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) +`FUN_100186b0` (RVA `0x186b0`) перечисляет устройства типа `0x1a`, а +`FUN_100198e0` (RVA `0x198e0`) суммирует успешные запросы IID `0x204` с selector +`0xe00`. В парной `Control.dll` выше `FUN_1002bb40` (RVA `0x2bb40`) для типа +`0x1a` вызывает selector `0x300`, описанный в этом разделе. Затем Behavior query +`0x1002` (RVA `0xa490`) умножает полученную сумму на текущее значение block +`+0x820`, поле `+0x58` `Transfer_Ore_OffBoard` (имя — RVA `0x22e20`, default +5.0 — RVA `0x22d90`; config selector 2 загружает блок через RVA `0x0a1a0`). +Отдельный query `0x1005` (RVA `0xa490`) возвращает поле `+0x60` +`Transfer_Power_Out` (имя — RVA `0x22e20`, default 100.0 — RVA `0x22d90`; +setter — RVA `0xaa40`). Rust helper пока описывает один Control selector `0x300` +при явно переданных входах; он не реализует эту сумму, Behavior-умножение или +подключение к игровому циклу. + +На x87 control word `0x007f` каждое умножение округляет мантиссу до 24 бит, +но промежуточная степень может выйти за диапазон `f32` и вернуться в него при +следующем умножении. Rust helper поэтому принимает явные текущие значения +provider и multiplier, проверяет tag, ключ и строку NDP, сохраняет порядок +операций, но поддерживает только конечные нормализованные значения или ноль на +входе, промежуточном и конечном этапах. Остальные случаи за этим численным +диапазоном он отклоняет; полный x87 accumulator здесь не эмулируется. + +Лицензированный тест на Unit DAT `UNITS/BUILDS/MINE/min_l_n1.dat` проходит через +компонент `fr_l_mine.ctl` и sidecar `fr_l_mine.ndp`: у найденного tag-26 +дескриптора ключ равен `2` из `22` строк, а raw коэффициент — `0x3f800000`. +Тест передаёт helper-у искусственные текущие значения `1.0` только для проверки +записи и привязки ключа; этот Unit DAT не является наблюдением живого Control +tick-а. + ### Control system и physical model `LoadControlSystem` загружает настройки controller-а: ограничения скорости, -- cgit v1.2.3