diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-20 08:06:17 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-20 08:06:17 +0300 |
| commit | 374db10daf5db88a52596edebc6cf5f2d560065f (patch) | |
| tree | b77e7bc5a09e4a4496c1ad555336cdac5079f9aa | |
| parent | caef8f8e6c0ae81b46fcab39cd4f2b74e1b29235 (diff) | |
| download | fparkan-trunk.tar.xz fparkan-trunk.zip | |
fix(runtime): model distributor share normalizationtrunk
| -rw-r--r-- | crates/fparkan-runtime/src/lib.rs | 240 | ||||
| -rw-r--r-- | crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs | 220 | ||||
| -rw-r--r-- | docs/appendices/knowledge-boundaries.md | 18 | ||||
| -rw-r--r-- | docs/appendices/script-vm.md | 50 | ||||
| -rw-r--r-- | docs/tomes/06-behavior.md | 30 |
5 files changed, 516 insertions, 42 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 8d42958..3964f78 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -116,7 +116,7 @@ pub struct MissionObjectDraft { } /// A mission object registered with one clan's native Behavior distributor. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct BehaviorRegisteredObject { /// Stable identity from the source TMA object order. pub original_id: OriginalObjectId, @@ -126,10 +126,16 @@ pub struct BehaviorRegisteredObject { pub selector: u32, /// Class word recovered from the root Unit DAT header. pub class_flags: u32, + /// Native share field at record offset `+0x04`. + pub share: f32, + /// Raw DWORDs at offsets `+0x08..+0x18`; the first controls whether + /// normalization treats this record as fixed, and the remaining words are + /// not yet assigned semantics. A normal native Add initializes all five to 0. + pub native_tail: [u32; 5], } /// Resource distributor state associated with one TMA clan's constructed AI. -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct BehaviorDistributorState { /// TMA clan index; it is not an index into the filtered script bundle list. pub clan_index: usize, @@ -1022,14 +1028,17 @@ fn load_mission_with_options( let Some(original_id) = draft.original_id else { continue; }; - distributors[clan_index] - .registered_objects - .push(BehaviorRegisteredObject { + add_behavior_registered_object( + &mut distributors[clan_index], + BehaviorRegisteredObject { original_id, handle: *handle, selector: draft.raw_after_resource, class_flags, - }); + share: 0.0, + native_tail: [0; 5], + }, + ); } distributors } else { @@ -1409,6 +1418,69 @@ fn has_behavior_registration_key(class_flags: u32) -> bool { class_flags & BEHAVIOR_REGISTRATION_KEY_FLAG != 0 } +fn add_behavior_registered_object( + distributor: &mut BehaviorDistributorState, + mut object: BehaviorRegisteredObject, +) { + let existing_index = distributor + .registered_objects + .iter() + .position(|entry| entry.handle == object.handle); + let count = distributor.registered_objects.len() + usize::from(existing_index.is_none()); + + // Native Add/normalize ran with x87 control word 0x007f. Keep the list-order + // arithmetic in f32 so each operation has the observed 24-bit precision. + let share = 1.0_f32 / count as f32; + let handle = object.handle; + if let Some(index) = existing_index { + distributor.registered_objects[index].share = share; + } else { + object.share = share; + object.native_tail = [0; 5]; + distributor.registered_objects.push(object); + } + + normalize_behavior_distributor(distributor, handle); +} + +fn normalize_behavior_distributor( + distributor: &mut BehaviorDistributorState, + fixed_handle: ObjectHandle, +) { + let mut fixed_sum = 0.0_f32; + let mut free_sum = 0.0_f32; + let mut adjustable_count = 0_usize; + + for object in &distributor.registered_objects { + if object.handle == fixed_handle || object.native_tail[0] != 0 { + fixed_sum = fixed_sum + object.share; + } else { + free_sum = free_sum + object.share; + adjustable_count += 1; + } + } + + if adjustable_count == 0 { + return; + } + + if free_sum <= 0.0 { + let share = (1.0_f32 - fixed_sum) / adjustable_count as f32; + for object in &mut distributor.registered_objects { + if object.handle != fixed_handle && object.native_tail[0] == 0 { + object.share = share; + } + } + } else { + let factor = (1.0_f32 - fixed_sum) / free_sum; + for object in &mut distributor.registered_objects { + if object.handle != fixed_handle && object.native_tail[0] == 0 { + object.share = object.share * factor; + } + } + } +} + fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPath, EngineError> { normalize_relative(value.as_bytes(), PathPolicy::StrictLegacy).map_err(|source| { EngineError::Path { @@ -2421,6 +2493,8 @@ mod tests { draft.resource_name_raw.as_slice(), object.selector, object.class_flags, + object.share.to_bits(), + object.native_tail, ) }) .collect::<Vec<_>>(), @@ -2431,6 +2505,8 @@ mod tests { &b"UNITS\\BUILDS\\PLANT\\wor_l_n1.dat"[..], 0, 0x8000_0010, + 0x3f00_0000, + [0; 5], ), ( 0, @@ -2438,6 +2514,8 @@ mod tests { &b"UNITS\\BUILDS\\GENER\\gener01.dat"[..], 0, 0x8000_0002, + 0x3f00_0000, + [0; 5], ), ( 1, @@ -2445,6 +2523,8 @@ mod tests { &b"UNITS\\BUILDS\\PLANT\\wor_l_n1.dat"[..], 1, 0x8000_0010, + 0x3f00_0000, + [0; 5], ), ( 1, @@ -2452,6 +2532,8 @@ mod tests { &b"UNITS\\BUILDS\\GENER\\gener01.dat"[..], 1, 0x8000_0002, + 0x3f00_0000, + [0; 5], ), ] ); @@ -2519,6 +2601,152 @@ mod tests { ); } + fn behavior_test_object( + slot: u32, + share: f32, + native_tail: [u32; 5], + ) -> BehaviorRegisteredObject { + BehaviorRegisteredObject { + original_id: OriginalObjectId(slot), + handle: ObjectHandle { + generation: 0, + slot, + }, + selector: 0, + class_flags: 0, + share, + native_tail, + } + } + + #[test] + fn behavior_distributor_add_uses_handle_and_preserves_duplicate_tail() { + let mut distributor = BehaviorDistributorState { + clan_index: 0, + super_ai_visible: true, + registered_objects: Vec::new(), + }; + + add_behavior_registered_object(&mut distributor, behavior_test_object(4, 0.0, [0; 5])); + add_behavior_registered_object(&mut distributor, behavior_test_object(6, 0.0, [0; 5])); + assert_eq!( + distributor + .registered_objects + .iter() + .map(|object| object.share.to_bits()) + .collect::<Vec<_>>(), + vec![0x3f00_0000, 0x3f00_0000] + ); + + distributor.registered_objects[0].share = 0.2; + distributor.registered_objects[0].native_tail = [0xa1, 0xa2, 0xa3, 0xa4, 0xa5]; + add_behavior_registered_object(&mut distributor, behavior_test_object(4, 0.0, [0; 5])); + assert_eq!( + distributor.registered_objects[0].share.to_bits(), + 0x3f00_0000 + ); + assert_eq!( + distributor.registered_objects[0].native_tail, + [0xa1, 0xa2, 0xa3, 0xa4, 0xa5] + ); + + add_behavior_registered_object(&mut distributor, behavior_test_object(8, 0.0, [0; 5])); + assert_eq!( + distributor + .registered_objects + .iter() + .map(|object| object.share.to_bits()) + .collect::<Vec<_>>(), + vec![0x3f00_0000, 0x3e2a_aaa8, 0x3eaa_aaab] + ); + assert_eq!( + distributor + .registered_objects + .iter() + .map(|object| object.native_tail) + .collect::<Vec<_>>(), + vec![[0xa1, 0xa2, 0xa3, 0xa4, 0xa5], [0; 5], [0; 5]] + ); + } + + #[test] + fn behavior_distributor_normalize_matches_native_24_bit_vectors() { + let mut pinned = BehaviorDistributorState { + clan_index: 0, + super_ai_visible: true, + registered_objects: vec![ + behavior_test_object(0x11, 0.2, [0; 5]), + behavior_test_object(0x22, 0.3, [1, 0x11, 0x22, 0x33, 0x44]), + behavior_test_object(0x33, 0.3, [0; 5]), + ], + }; + normalize_behavior_distributor( + &mut pinned, + ObjectHandle { + generation: 0, + slot: 0x11, + }, + ); + assert_eq!( + pinned + .registered_objects + .iter() + .map(|object| object.share.to_bits()) + .collect::<Vec<_>>(), + vec![0x3e4c_cccd, 0x3e99_999a, 0x3f00_0000] + ); + + let mut zero_sum = BehaviorDistributorState { + clan_index: 0, + super_ai_visible: true, + registered_objects: vec![ + behavior_test_object(0x41, 0.25, [0; 5]), + behavior_test_object(0x42, 0.0, [0; 5]), + behavior_test_object(0x43, 0.0, [0; 5]), + ], + }; + normalize_behavior_distributor( + &mut zero_sum, + ObjectHandle { + generation: 0, + slot: 0x41, + }, + ); + assert_eq!( + zero_sum + .registered_objects + .iter() + .map(|object| object.share.to_bits()) + .collect::<Vec<_>>(), + vec![0x3e80_0000, 0x3ec0_0000, 0x3ec0_0000] + ); + + let mut near_rounding = BehaviorDistributorState { + clan_index: 0, + super_ai_visible: true, + registered_objects: vec![ + behavior_test_object(0x51, 0.5, [0; 5]), + behavior_test_object(0x52, 1.0, [0; 5]), + behavior_test_object(0x53, f32::from_bits(0x3380_0000), [0; 5]), + ], + }; + normalize_behavior_distributor( + &mut near_rounding, + ObjectHandle { + generation: 0, + slot: 0x51, + }, + ); + assert_eq!( + near_rounding + .registered_objects + .iter() + .map(|object| object.share.to_bits()) + .collect::<Vec<_>>(), + vec![0x3f00_0000, 0x3f00_0000, 0x3300_0000] + ); + } + #[test] #[ignore = "requires licensed corpus"] fn licensed_campaign_preview_keeps_unsupported_init_without_partial_writes() { 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 2d5e206..5c7c4f2 100644 --- a/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs +++ b/crates/fparkan-runtime/tests/behavior_distributor_tick_native_oracle.cs @@ -5,9 +5,9 @@ using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; -// Calls the original x86 Behavior.dll distributor tick against synthetic vtables. -// Only the imported GetSystemArealMap IAT slot is replaced; the tick instructions -// and its internal helpers remain the installed DLL's original code. +// Calls the original x86 Behavior.dll distributor Add, normalize, and tick paths +// against synthetic storage. Only GetSystemArealMap and the bounded vector backing +// are substituted; Behavior's corresponding instructions remain the installed DLL. internal static class BehaviorDistributorTickOracle { private const uint LoadWithAlteredSearchPath = 0x00000008; private const uint MemCommitReserve = 0x00003000; @@ -16,6 +16,8 @@ internal static class BehaviorDistributorTickOracle { private const uint PageReadWrite = 0x04; private const string ExpectedBehaviorSha256 = "ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99"; private const int TickRva = 0x19e80; + private const int AddObjectRva = 0x1aac0; + private const int NormalizeRva = 0x1ac90; private const int GetSystemArealMapIatRva = 0x59008; private const uint ResourceProperty = 0x02000100; private static Fixture active; @@ -28,7 +30,12 @@ internal static class BehaviorDistributorTickOracle { [DllImport("kernel32.dll", SetLastError = true)] private static extern bool VirtualProtect(IntPtr address, UIntPtr size, uint protect, out uint oldProtect); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate IntPtr AreaMapGetter(); + [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.StdCall)] private delegate void NativeAddObject(IntPtr self, uint key); + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] private delegate void NativeNormalize(IntPtr self, uint key); + [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate void VectorInsert(IntPtr self, IntPtr source, uint count, uint position); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate IntPtr MapLookup(IntPtr self, uint key); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int ObjectState(IntPtr self); [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate uint ObjectClass(IntPtr self); @@ -40,6 +47,7 @@ internal static class BehaviorDistributorTickOracle { [UnmanagedFunctionPointer(CallingConvention.StdCall)] private delegate int InterfaceSetProperty(IntPtr self, uint selector, IntPtr value); private static readonly AreaMapGetter AreaMapGetterCallback = GetAreaMap; + private static readonly VectorInsert VectorInsertCallback = InsertVector; private static readonly MapLookup MapLookupCallback = Lookup; private static readonly ObjectState ObjectStateCallback = GetObjectState; private static readonly ObjectClass ObjectClassCallback = GetObjectClass; @@ -53,6 +61,10 @@ internal static class BehaviorDistributorTickOracle { private static IntPtr queryObjectThunk; private static IntPtr getInterfacePropertyThunk; private static IntPtr setInterfacePropertyThunk; + private static IntPtr vectorInsertThunk; + private static IntPtr x87ControlWordCell; + private static X87ControlWordReader x87ControlWordReader; + private static X87ControlWordWriter x87ControlWordWriter; private sealed class NativeObject { public IntPtr Self; @@ -84,6 +96,7 @@ internal static class BehaviorDistributorTickOracle { private sealed class Fixture : IDisposable { public IntPtr Distributor; public IntPtr Entries; + public IntPtr VectorVtable; public IntPtr Manager; public IntPtr ManagerVtable; public readonly List<IntPtr> Allocations = new List<IntPtr>(); @@ -91,6 +104,7 @@ internal static class BehaviorDistributorTickOracle { public readonly List<string> Calls = new List<string>(); public readonly List<string> Trace = new List<string>(); public int LookupCount; + public int VectorInsertCalls; public NativeObject AddObject(uint key, uint classCode, float powerRate, float oreRate, float property, int state) { NativeObject item = new NativeObject(); item.Key = key; @@ -129,8 +143,14 @@ internal static class BehaviorDistributorTickOracle { } public void MakeDistributor(params uint[] keys) { Distributor = Allocate(0x44); - Entries = Allocate(Math.Max(1, keys.Length) * 0x1c); + const int capacity = 32; + if (keys.Length > capacity) throw new InvalidOperationException("synthetic distributor capacity exceeded"); + Entries = Allocate(capacity * 0x1c); + VectorVtable = Allocate(0x20); + Marshal.WriteIntPtr(VectorVtable, 0x10, vectorInsertThunk); + Marshal.WriteIntPtr(Distributor, 0x1c, VectorVtable); Marshal.WriteInt32(Distributor, 0x20, keys.Length); + Marshal.WriteInt32(Distributor, 0x24, capacity); Marshal.WriteIntPtr(Distributor, 0x28, Entries); for (int i = 0; i < keys.Length; i++) { IntPtr entry = IntPtr.Add(Entries, i * 0x1c); @@ -152,6 +172,21 @@ internal static class BehaviorDistributorTickOracle { } private static IntPtr GetAreaMap() { return active.Manager; } + private static void InsertVector(IntPtr self, IntPtr source, uint count, uint position) { + int length = Marshal.ReadInt32(self, 4); + int capacity = Marshal.ReadInt32(self, 8); + IntPtr destination = Marshal.ReadIntPtr(self, 12); + Expect(count > 0 && count <= (uint)(capacity - length), "bounded vector insertion capacity"); + Expect(position == 0xffffffff || position == (uint)length, "native append position"); + byte[] entry = new byte[0x1c]; + for (uint index = 0; index < count; index++) { + Marshal.Copy(IntPtr.Add(source, unchecked((int)(index * 0x1c))), entry, 0, entry.Length); + Marshal.Copy(entry, 0, IntPtr.Add(destination, (length + (int)index) * 0x1c), entry.Length); + } + Marshal.WriteInt32(self, 4, length + (int)count); + active.VectorInsertCalls++; + active.Trace.Add("insert:" + count.ToString() + ":" + position.ToString("x8")); + } private static IntPtr Lookup(IntPtr self, uint key) { active.Calls.Add("lookup:" + key.ToString("x8")); active.LookupCount++; @@ -283,12 +318,17 @@ internal static class BehaviorDistributorTickOracle { queryObjectThunk = WriteFastcallToStdcallThunk(0x00, Marshal.GetFunctionPointerForDelegate(ObjectQueryCallback)); getInterfacePropertyThunk = WriteFastcallToStdcallThunk(0x40, Marshal.GetFunctionPointerForDelegate(InterfaceGetCallback)); setInterfacePropertyThunk = WriteFastcallToStdcallThunk(0x80, Marshal.GetFunctionPointerForDelegate(InterfaceSetCallback)); + vectorInsertThunk = WriteThisCallThreeArgsToStdcallThunk(0xc0, Marshal.GetFunctionPointerForDelegate(VectorInsertCallback)); + x87ControlWordCell = Marshal.AllocHGlobal(2); + Marshal.WriteInt16(x87ControlWordCell, 0); + x87ControlWordReader = (X87ControlWordReader)Marshal.GetDelegateForFunctionPointer(WriteX87ControlWordReader(0x100), typeof(X87ControlWordReader)); + x87ControlWordWriter = (X87ControlWordWriter)Marshal.GetDelegateForFunctionPointer(WriteX87ControlWordWriter(0x120), typeof(X87ControlWordWriter)); if (!VirtualProtect(import, new UIntPtr((uint)IntPtr.Size), PageReadWrite, out oldProtection)) throw new InvalidOperationException("VirtualProtect(GetSystemArealMap IAT) failed: " + Marshal.GetLastWin32Error()); protectionChanged = true; Marshal.WriteIntPtr(import, Marshal.GetFunctionPointerForDelegate(AreaMapGetterCallback)); RunSmokeCases(module); - Console.WriteLine("original Behavior.dll distributor tick oracle passed (RVA 0x19e80; only GetSystemArealMap IAT substituted; this does not compare Rust parity)."); + 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; } catch (Exception error) { @@ -301,18 +341,30 @@ internal static class BehaviorDistributorTickOracle { uint ignored; VirtualProtect(import, new UIntPtr((uint)IntPtr.Size), oldProtection, out ignored); } - GC.KeepAlive(AreaMapGetterCallback); GC.KeepAlive(MapLookupCallback); + GC.KeepAlive(AreaMapGetterCallback); GC.KeepAlive(VectorInsertCallback); GC.KeepAlive(x87ControlWordReader); GC.KeepAlive(x87ControlWordWriter); GC.KeepAlive(MapLookupCallback); GC.KeepAlive(ObjectStateCallback); GC.KeepAlive(ObjectClassCallback); GC.KeepAlive(ObjectQueryCallback); GC.KeepAlive(ObjectRateCallback); GC.KeepAlive(ObjectGetPropertyCallback); GC.KeepAlive(ObjectSetPropertyCallback); GC.KeepAlive(InterfaceGetCallback); GC.KeepAlive(InterfaceSetCallback); if (thunksAllocated && !VirtualFree(fastcallThunks, UIntPtr.Zero, MemRelease)) throw new InvalidOperationException("VirtualFree(fastcall callback thunks) failed: " + Marshal.GetLastWin32Error()); + if (x87ControlWordCell != IntPtr.Zero) { Marshal.FreeHGlobal(x87ControlWordCell); x87ControlWordCell = IntPtr.Zero; } FreeLibrary(module); } } private static void RunSmokeCases(IntPtr module) { + ushort savedControlWord = CurrentX87ControlWord(); + Console.WriteLine("x87-control-word-before=0x" + savedControlWord.ToString("x4") + " (x86 oracle process)"); + try { + SetX87ControlWord(0x007f); + Console.WriteLine("native-add-normalize-x87-control-word=0x" + CurrentX87ControlWord().ToString("x4")); + RunNativeAddCases(module); + RunNativeNormalizeCases(module); + } finally { + SetX87ControlWord(savedControlWord); + Console.WriteLine("x87-control-word-restored=0x" + CurrentX87ControlWord().ToString("x4")); + } TickDelegate tick = (TickDelegate)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, TickRva), typeof(TickDelegate)); using (Fixture empty = NewFixture()) { empty.MakeDistributor(); active = empty; tick(empty.Distributor, 0.0f); @@ -335,6 +387,137 @@ internal static class BehaviorDistributorTickOracle { RunSkippedCase(tick); } + private static ushort CurrentX87ControlWord() { + if (x87ControlWordReader == null || x87ControlWordCell == IntPtr.Zero) + throw new InvalidOperationException("x87 control-word reader is not initialized"); + x87ControlWordReader(); + return unchecked((ushort)Marshal.ReadInt16(x87ControlWordCell)); + } + + private static void SetX87ControlWord(ushort value) { + if (x87ControlWordWriter == null || x87ControlWordCell == IntPtr.Zero) + throw new InvalidOperationException("x87 control-word writer is not initialized"); + Marshal.WriteInt16(x87ControlWordCell, unchecked((short)value)); + x87ControlWordWriter(); + Expect(CurrentX87ControlWord() == value, "x87 control word 0x" + value.ToString("x4")); + } + + private static void AddWithGameControl(NativeAddObject add, IntPtr distributor, uint key) { + Expect(CurrentX87ControlWord() == 0x007f, "game x87 control word before native Add"); + add(distributor, key); + Expect(CurrentX87ControlWord() == 0x007f, "game x87 control word after native Add"); + } + + private static void NormalizeWithGameControl(NativeNormalize normalize, IntPtr distributor, uint key) { + Expect(CurrentX87ControlWord() == 0x007f, "game x87 control word before native normalize"); + normalize(distributor, key); + Expect(CurrentX87ControlWord() == 0x007f, "game x87 control word after native normalize"); + } + + private static void RunNativeAddCases(IntPtr module) { + NativeAddObject add = (NativeAddObject)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, AddObjectRva), typeof(NativeAddObject)); + using (Fixture fixture = NewFixture()) { + fixture.MakeDistributor(); + active = fixture; + AddWithGameControl(add, fixture.Distributor, 0x80000004); + Expect(Marshal.ReadInt32(fixture.Distributor, 0x20) == 1, "Add inserts a new first key"); + ExpectEntry(fixture.Entries, 0, 0x80000004, 0x3f800000, 0, 0, 0, 0, 0); + + AddWithGameControl(add, fixture.Distributor, 0x80000006); + Expect(Marshal.ReadInt32(fixture.Distributor, 0x20) == 2, "Add appends a second key"); + ExpectEntry(fixture.Entries, 0, 0x80000004, 0x3f000000, 0, 0, 0, 0, 0); + ExpectEntry(fixture.Entries, 1, 0x80000006, 0x3f000000, 0, 0, 0, 0, 0); + + WriteEntry(fixture.Entries, 0, 0x80000004, 0.2f, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5); + AddWithGameControl(add, fixture.Distributor, 0x80000004); + Expect(Marshal.ReadInt32(fixture.Distributor, 0x20) == 2, "Add on an existing key does not append"); + Expect(fixture.VectorInsertCalls == 2, "Add inserts exactly the two new keys"); + ExpectEntry(fixture.Entries, 0, 0x80000004, 0x3f000000, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5); + ExpectEntry(fixture.Entries, 1, 0x80000006, 0x3f000000, 0, 0, 0, 0, 0); + + AddWithGameControl(add, fixture.Distributor, 0x80000008); + Expect(Marshal.ReadInt32(fixture.Distributor, 0x20) == 3, "Add appends a third key"); + Expect(fixture.VectorInsertCalls == 3, "Add inserts each new key exactly once"); + ExpectEntry(fixture.Entries, 0, 0x80000004, 0x3f000000, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5); + uint[] third = ReadEntry(IntPtr.Add(fixture.Entries, 0x38)); + Console.WriteLine("native-add-third-share: " + Float(third[1]) + " bits=" + Hex(third[1])); + Expect(third[0] == 0x80000008 && third[2] == 0 && third[3] == 0 && third[4] == 0 && third[5] == 0 && third[6] == 0, + "Add initializes the third record apart from its observed share"); + ExpectEntry(fixture.Entries, 1, 0x80000006, 0x3e2aaaa8, 0, 0, 0, 0, 0); + ExpectEntry(fixture.Entries, 2, 0x80000008, 0x3eaaaaab, 0, 0, 0, 0, 0); + Console.WriteLine("native-add: count=3 inserts=" + fixture.VectorInsertCalls + " trace=" + String.Join(",", fixture.Trace.ToArray()) + + " entries=[" + FormatEntry(fixture.Entries) + ";" + FormatEntry(IntPtr.Add(fixture.Entries, 0x1c)) + ";" + + FormatEntry(IntPtr.Add(fixture.Entries, 0x38)) + "]"); + } + } + + private static void RunNativeNormalizeCases(IntPtr module) { + NativeNormalize normalize = (NativeNormalize)Marshal.GetDelegateForFunctionPointer(IntPtr.Add(module, NormalizeRva), typeof(NativeNormalize)); + using (Fixture fixture = NewFixture()) { + fixture.MakeDistributor(0x11, 0x22, 0x33); + active = fixture; + WriteEntry(fixture.Entries, 0, 0x11, 0.2f, 0, 0xaaaaaaaa, 0xbbbbbbbb, 0xcccccccc, 0xdddddddd); + WriteEntry(fixture.Entries, 1, 0x22, 0.3f, 1, 0x11111111, 0x22222222, 0x33333333, 0x44444444); + WriteEntry(fixture.Entries, 2, 0x33, 0.3f, 0, 0x55555555, 0x66666666, 0x77777777, 0x88888888); + NormalizeWithGameControl(normalize, fixture.Distributor, 0x11); + ExpectEntry(fixture.Entries, 0, 0x11, FloatBitsUnsigned(0.2f), 0, 0xaaaaaaaa, 0xbbbbbbbb, 0xcccccccc, 0xdddddddd); + ExpectEntry(fixture.Entries, 1, 0x22, FloatBitsUnsigned(0.3f), 1, 0x11111111, 0x22222222, 0x33333333, 0x44444444); + ExpectEntry(fixture.Entries, 2, 0x33, 0x3f000000, 0, 0x55555555, 0x66666666, 0x77777777, 0x88888888); + Console.WriteLine("native-normalize-pinned: shares=[" + Float(ReadEntry(fixture.Entries)[1]) + "," + + Float(ReadEntry(IntPtr.Add(fixture.Entries, 0x1c))[1]) + "," + + Float(ReadEntry(IntPtr.Add(fixture.Entries, 0x38))[1]) + "] flags=[0,1,0]"); + } + using (Fixture fixture = NewFixture()) { + fixture.MakeDistributor(0x41, 0x42, 0x43); + active = fixture; + WriteEntry(fixture.Entries, 0, 0x41, 0.25f, 0, 0, 0, 0, 0); + WriteEntry(fixture.Entries, 1, 0x42, 0.0f, 0, 0, 0, 0, 0); + WriteEntry(fixture.Entries, 2, 0x43, 0.0f, 0, 0, 0, 0, 0); + NormalizeWithGameControl(normalize, fixture.Distributor, 0x41); + ExpectEntry(fixture.Entries, 0, 0x41, 0x3e800000, 0, 0, 0, 0, 0); + ExpectEntry(fixture.Entries, 1, 0x42, 0x3ec00000, 0, 0, 0, 0, 0); + ExpectEntry(fixture.Entries, 2, 0x43, 0x3ec00000, 0, 0, 0, 0, 0); + Console.WriteLine("native-normalize-zero-free-sum: shares=[0.25,0.375,0.375]"); + } + using (Fixture fixture = NewFixture()) { + fixture.MakeDistributor(0x51, 0x52, 0x53); + active = fixture; + WriteEntry(fixture.Entries, 0, 0x51, 0.5f, 0, 0, 0, 0, 0); + WriteEntry(fixture.Entries, 1, 0x52, 1.0f, 0, 0, 0, 0, 0); + WriteEntry(fixture.Entries, 2, 0x53, 5.9604644775390625e-8f, 0, 0, 0, 0, 0); + NormalizeWithGameControl(normalize, fixture.Distributor, 0x51); + uint[] nearFirst = ReadEntry(fixture.Entries); + uint[] nearSecond = ReadEntry(IntPtr.Add(fixture.Entries, 0x1c)); + uint[] nearThird = ReadEntry(IntPtr.Add(fixture.Entries, 0x38)); + Console.WriteLine("native-normalize-near-rounding: shares=[" + Float(nearFirst[1]) + "," + Float(nearSecond[1]) + "," + Float(nearThird[1]) + "] bits=[" + Hex(nearFirst[1]) + "," + Hex(nearSecond[1]) + "," + Hex(nearThird[1]) + "]"); + ExpectEntry(fixture.Entries, 0, 0x51, 0x3f000000, 0, 0, 0, 0, 0); + ExpectEntry(fixture.Entries, 1, 0x52, 0x3f000000, 0, 0, 0, 0, 0); + ExpectEntry(fixture.Entries, 2, 0x53, 0x33000000, 0, 0, 0, 0, 0); + } + } + + private static void WriteEntry(IntPtr entries, int index, uint key, float weight, uint pinned, + uint word3, uint word4, uint word5, uint word6) { + IntPtr entry = IntPtr.Add(entries, index * 0x1c); + Marshal.WriteInt32(entry, 0x00, unchecked((int)key)); + Marshal.WriteInt32(entry, 0x04, FloatBits(weight)); + Marshal.WriteInt32(entry, 0x08, unchecked((int)pinned)); + Marshal.WriteInt32(entry, 0x0c, unchecked((int)word3)); + Marshal.WriteInt32(entry, 0x10, unchecked((int)word4)); + Marshal.WriteInt32(entry, 0x14, unchecked((int)word5)); + Marshal.WriteInt32(entry, 0x18, unchecked((int)word6)); + } + + private static void ExpectEntry(IntPtr entries, int index, uint key, uint weight, uint pinned, + uint word3, uint word4, uint word5, uint word6) { + uint[] words = ReadEntry(IntPtr.Add(entries, index * 0x1c)); + Expect(words[0] == key && words[1] == weight && words[2] == pinned && words[3] == word3 && + words[4] == word4 && words[5] == word5 && words[6] == word6, + "native entry " + index + " fields [" + String.Join(",", Array.ConvertAll(words, Hex)) + "]"); + } + + private static uint FloatBitsUnsigned(float value) { return unchecked((uint)FloatBits(value)); } + private static Fixture NewFixture() { Fixture fixture = new Fixture(); fixture.ManagerVtable = Marshal.AllocHGlobal(0x60); fixture.Allocations.Add(fixture.ManagerVtable); Zero(fixture.ManagerVtable, 0x60); @@ -570,6 +753,29 @@ internal static class BehaviorDistributorTickOracle { Marshal.Copy(code, 0, thunk, code.Length); return thunk; } + private static IntPtr WriteThisCallThreeArgsToStdcallThunk(int offset, IntPtr stdcallTarget) { + // Adapt ECX=self plus three stack arguments to stdcall(self, arg1, arg2, arg3). + byte[] code = { 0xff, 0x74, 0x24, 0x0c, 0xff, 0x74, 0x24, 0x0c, 0xff, 0x74, 0x24, 0x0c, + 0x51, 0xb8, 0, 0, 0, 0, 0xff, 0xd0, 0xc2, 0x0c, 0x00 }; + WriteImm32(code, 14, stdcallTarget.ToInt32()); + IntPtr thunk = IntPtr.Add(fastcallThunks, offset); + Marshal.Copy(code, 0, thunk, code.Length); + return thunk; + } + private static IntPtr WriteX87ControlWordReader(int offset) { + byte[] code = { 0xd9, 0x3d, 0, 0, 0, 0, 0xc3 }; // fnstcw [absolute address]; ret + WriteImm32(code, 2, x87ControlWordCell.ToInt32()); + IntPtr thunk = IntPtr.Add(fastcallThunks, offset); + Marshal.Copy(code, 0, thunk, code.Length); + return thunk; + } + private static IntPtr WriteX87ControlWordWriter(int offset) { + byte[] code = { 0xd9, 0x2d, 0, 0, 0, 0, 0xc3 }; // fldcw [absolute address]; ret + WriteImm32(code, 2, x87ControlWordCell.ToInt32()); + IntPtr thunk = IntPtr.Add(fastcallThunks, offset); + Marshal.Copy(code, 0, thunk, code.Length); + return thunk; + } private static void WriteImm32(byte[] code, int offset, int value) { byte[] bytes = BitConverter.GetBytes(value); Buffer.BlockCopy(bytes, 0, code, offset, 4); @@ -585,7 +791,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 tick oracle failed: " + message); } + private static void Expect(bool value, string message) { if (!value) throw new InvalidOperationException("original Behavior oracle failed: " + message); } private static void ExpectLookupOrder(Fixture fixture, params string[] keys) { List<string> actual = new List<string>(); 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 b10a002..873497c 100644 --- a/docs/appendices/knowledge-boundaries.md +++ b/docs/appendices/knowledge-boundaries.md @@ -157,13 +157,17 @@ Behavior (`Behavior.dll`, RVA `0x3600`) вызывает `World3D!stdGetGameMode подтверждает только состав списков, не точную эквивалентность всей загрузки или регулярного шага Behavior. -Нативный `Behavior.dll!Add` (RVA `0x1aac0`) создаёт для каждого участника запись -размером `0x1c` с начальной долей `1 / count`, а `normalize` (RVA `0x1ac90`) -перенормирует прежние доли после добавления. Rust пока хранит только членство: -поля записи, доли и их изменение ещё не исполняются. Не восстановлено и -соединение с реальными интерфейсами ArealMap, необходимыми для регулярного -вызова `FUN_10019e80`; остальной Behavior tick flow и общий dispatcher событий -также остаются открытыми. +Нативный `Behavior.dll!Add` (RVA `0x1aac0`) и `normalize` (RVA `0x1ac90`) +проверены прямыми x86-вызовами на синтетических записях при x87 control word +`0x007f`, измеренном также у игры на входах обоих методов. Rust повторяет +начальные доли и нормализацию отдельными операциями `f32`, а повторную запись +сопоставляет по `ObjectHandle`, меняя только долю существующего участника. +Пять DWORD после доли хранятся сырыми; ненулевое первое слово закрепляет вес, +смысл четырёх следующих не установлен. Лицензированный AutoDemo-тест проверяет +список TMA-объектов и получившиеся начальные доли по кланам, но это не сравнение +живых весов миссии с полями исходных runtime-записей. Не восстановлено соединение +с реальными интерфейсами ArealMap, нужными `FUN_10019e80`; полный Behavior tick +flow и общий dispatcher событий также остаются открытыми. Для `Handler(30)` отдельно подтверждены обратный вызов `iron3d.dll` RVA `0x611d0` (SHA-256 `d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`), diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 4020ebb..c15b208 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -441,15 +441,36 @@ selector `1` на интерфейсе получателя становится `0x3f2aaaab`). Эти векторы проверяют арифметику исходной функции, но не распределение реальной карты. -Текущие типы Rust не дают честно наполнить этот список: мир хранит -идентификаторы и преобразования объектов, но не нужные tick ресурсные поля; -`MissionObjectDraft.properties` сохраняет сырые слова без связи с QI -`0x16`/`0x204` или свойством `0x02000100`. Методы добавления и перенастройки -строк `FUN_1001a9a0` и `FUN_1001aac0` доступны через entries vtable -`0x10059794` по смещениям `+0x0c` и `+0x10`; кто именно вызывает эти методы и -какие значения передаёт, пока не установлено. TMA-anchors и сырые preview -drafts не используются вместо неизвестной регистрации. `step_headless` по-прежнему -шагает только `World`. +Полная загрузка Rust теперь собирает этот список из runtime `ObjectHandle` и +сохраняет веса вместе с пятью исходными DWORD хвоста каждой записи. Членство +строится только для полного режима; preview остаётся статическим. Это пока не +даёт ресурсных полей и интерфейсов ArealMap для `FUN_10019e80`, а +`step_headless` по-прежнему шагает только `World`. + +### Добавление и нормализация долей + +В этой же GOG `Behavior.dll` (SHA-256 +`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) +`FUN_1001aac0` (RVA `0x1aac0`) добавляет запись размером `0x1c`. Её слово по +`+0x04` — доля `f32`, а слова `+0x08..+0x18` копируются как сырой хвост. +Новая запись начинается с доли `1 / count` и нулевого хвоста. При повторном +ключе запись не вставляется снова: меняется только её доля, остальные пять +DWORD сохраняются. После каждого вызова выполняется `FUN_1001ac90` (RVA +`0x1ac90`). Она считает долю целевого ключа и записей с ненулевым первым словом +хвоста фиксированной. Если сумма остальных долей положительна, остаток от +единицы распределяется пропорционально; при нулевой или отрицательной сумме он +делится поровну между изменяемыми записями. Проверенный ветвящийся путь также +направляет unordered-сумму в пропорциональную ветвь, но стандартный набор этих +тестов содержит конечные значения. + +Инструкции Add и normalize выполнялись при x87 control word `0x007f`, то есть +с 24-битной точностью мантиссы; такой же CW измерен у оригинальной игры на +входах обоих методов. Поэтому Rust повторяет их в порядке списка отдельными +операциями `f32`, без раннего перехода в `f64`. Native x86 oracle проверяет +три последовательных Add, повторный ключ с sentinel-хвостом, фиксированную +запись, нулевую сумму и пограничный вектор округления. В последнем доли имеют +биты `[0x3f000000, 0x3f000000, 0x33000000]`. Эти сценарии сверяют локальную +арифметику на стандартных конечных данных, а не весь игровый запуск. Для проверки использованы три x86 helper-а на синтетической памяти. Caller oracle вызывает оригинальные `iron3d.dll` entry points и проверяет преобразование @@ -485,7 +506,8 @@ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } exit $LASTEXITCODE ``` -Отдельный oracle distributor tick компилируется и запускается так: +Native oracle Add, normalize и distributor tick компилируется и запускается +так: ```powershell $env:FPARKAN_ORIGINAL_BEHAVIOR_DLL = 'C:\GOG Games\Parkan - Iron Strategy\Behavior.dll' @@ -498,8 +520,12 @@ exit $LASTEXITCODE Oracle проверяет SHA-256 оригинального `Behavior.dll`: `ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`. Harness -вызывает функцию по RVA `0x19e80` при единственной подмене импортного -слота `GetSystemArealMap`; машинный код tick не патчится. Проверяются нулевой +явно устанавливает x87 CW `0x007f` для вызовов Add (`0x1aac0`) и normalize +(`0x1ac90`), проверяет CW до и после каждого вызова и восстанавливает прежнее +значение перед tick suite. Для этих двух методов подменяется только bounded +вставка синтетического vector; их машинный код не патчится. Для tick helper +подменяет только IAT-слот `GetSystemArealMap` и вызывает RVA `0x19e80`. +Проверяются нулевой шаг, обе ветви селекторов скорости, пропуск состояния `0xfffe`, missing lookup, динамическое повторное чтение `0x1002`, ограниченный запас руды, разные веса, достаточное и недостаточное значение мощности. Это синтетические ArealMap diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index b30bc7d..c00705b 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -207,7 +207,9 @@ SuperAI для записей кланов, затем обновляет Behavi исполнен на исходной DLL с искусственными объектами. Rust пока не вызывает регулярный шаг: игровой мир ещё не предоставляет нужные ресурсные интерфейсы. При полной загрузке Rust уже восстанавливает членство объектов в клановых -списках Behavior, но сами записи распределителя и их доли пока не моделирует. +списках Behavior и задаёт их начальные доли. Остальные слова записи хранятся +сырыми: семантика только первого из пяти слов хвоста установлена для +нормализации. Runtime также не подменяет очередь оригинала: вызывающая сторона сама передаёт время, а локальный генератор Rust пока учитывает только случайные числа восстановленного стартового шага. @@ -241,10 +243,17 @@ Rust-тест повторно читает лицензированную миÑ TMA-записями и проверяет реальные `ObjectHandle`, но помечен `#[ignore]`, чтобы не требовать оригинальных ресурсов от остальных разработчиков. -Это пока только перечень участников. Оригинальный `Behavior.dll!Add` создаёт для -каждого участника запись размером `0x1c` с долей `1 / count`, после чего -нормирует прежние доли. Rust ещё не хранит эти поля и не исполняет их регулярный -обмен ресурсами. Поля не выводятся из TMA-соседнего слова или `OwnerId`. +Список распределителя хранит не только участников, но и долю каждого. При +добавлении двух объектов оба получают по половине общего веса. Если добавить +третий, движок оставляет долю новой записи около одной трети, а оставшийся вес +делит между прежними изменяемыми записями. Помеченная запись сохраняет свой вес, +как закреплённая часть общего набора. Повторное добавление того же объекта не +создаёт вторую строку и оставляет остальные слова записи нетронутыми. + +Rust хранит этот небольшой начальный список с долями. Он не смешивает selector +из TMA, владельца мирового объекта и внутренний ключ Behavior: связь в Rust +опирается на уже созданный `ObjectHandle`. Регулярный обмен ресурсами пока +остаётся отдельным этапом, потому что для него нужны интерфейсы игровых объектов. ### Регулярная передача ресурсов @@ -263,11 +272,12 @@ Resource Distributor работает со списком зарегистрир исходной `Behavior.dll`, но объекты и ответы интерфейсов заданы тестом; это ещё не воспроизведение реальной миссии. -Членство списка связано с реальным путём регистрации, но выполнение этой -миссии не сравнивалось с тем, как исходная DLL заполняет поля записи `0x1c` и -нормирует доли. Rust пока не хранит эти поля, а игровому миру ещё не хватает -ресурсных интерфейсов ArealMap для регулярного шага. Открытые вопросы и границы -проверки приведены в [границах знания](../appendices/knowledge-boundaries.md). +Для правила долей есть отдельная проверка исходной `Behavior.dll` на синтетических +записях при игровом x87 control word `0x007f`: проверены добавление, повторный +ключ, закреплённая запись, нулевая сумма и округление. Это позволяет Rust +сохранять начальный список, но пока не подтверждает веса реальных объектов в +живой миссии. Для регулярного шага по-прежнему не хватает интерфейсов ArealMap. +Остальные ограничения собраны в [границах знания](../appendices/knowledge-boundaries.md). Первый часто встречающийся table entry с side effect — `Handler(2)` (176 records в corpus). Он берёт active instruction, разрешает семь slots через |
