diff options
| author | Valentin Popov <valentin@popov.link> | 2026-10-20 00:31:26 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-10-20 00:31:26 +0300 |
| commit | a970974eb086372dc292a69b52b97b343da41e30 (patch) | |
| tree | f46e41c2fe6edc6f31e62095b687a8fe946ed39c | |
| parent | da0672bdf04b497df064e685bd1e2da9f9c4d2ca (diff) | |
| download | fparkan-a970974eb086372dc292a69b52b97b343da41e30.tar.xz fparkan-a970974eb086372dc292a69b52b97b343da41e30.zip | |
fix(script): preserve scheduler counter increment
| -rw-r--r-- | crates/fparkan-script/src/lib.rs | 28 | ||||
| -rw-r--r-- | crates/fparkan-script/tests/handler2_native_oracle.cs | 87 | ||||
| -rw-r--r-- | docs/appendices/script-vm.md | 37 |
3 files changed, 146 insertions, 6 deletions
diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs index 88c2d0a..010c62a 100644 --- a/crates/fparkan-script/src/lib.rs +++ b/crates/fparkan-script/src/lib.rs @@ -856,8 +856,10 @@ pub struct Handler2Record { pub key: Handler2RecordKey, /// Resolved slot 1 scalar. pub scalar_1: f32, - /// Initial and per-refresh counter word from resolved slot 2. + /// Current counter word stored at original record offset `+0x24`. pub counter: u32, + /// Original record offset `+0x28`: slot 2 captured when the record was created. + increment: u32, /// Resolved slot 3 word. pub word_3: u32, /// Resolved slot 6 scalar. @@ -892,8 +894,8 @@ impl Handler2RecordScheduler { /// /// The original compares the three identity words bit-for-bit. On an /// existing key, it refreshes only when `scalar_1` compares unequal; that - /// update replaces `scalar_1` and `scalar_6`, then adds the record's own - /// slot-2 counter word with x86 wrapping arithmetic. + /// update replaces `scalar_1` and `scalar_6`, then adds the slot-2 word + /// captured when that record was created, with x86 wrapping arithmetic. pub fn submit(&mut self, input: Handler2RecordInput) -> Handler2RecordUpdate { let key = Handler2RecordKey::from(input); if let Some((index, record)) = self @@ -905,7 +907,7 @@ impl Handler2RecordScheduler { if !handler_two_scalar_equal(record.scalar_1, input.scalar_1) { record.scalar_1 = input.scalar_1; record.scalar_6 = input.scalar_6; - record.counter = record.counter.wrapping_add(input.word_2); + record.counter = record.counter.wrapping_add(record.increment); return Handler2RecordUpdate { index, created: false, @@ -923,6 +925,7 @@ impl Handler2RecordScheduler { key, scalar_1: input.scalar_1, counter: input.word_2, + increment: input.word_2, word_3: input.word_3, scalar_6: input.scalar_6, }); @@ -1254,12 +1257,20 @@ mod tests { refreshed: true, } ); - assert_eq!(scheduler.records()[0].counter, 103); + assert_eq!(scheduler.records()[0].counter, 8); assert_eq!( scheduler.records()[0].scalar_6.to_bits(), 12.0_f32.to_bits() ); + let refreshed_again = handler_two_input(3.5, -0.0, 3.0, 13.0, 7); + assert!(scheduler.submit(refreshed_again).refreshed); + assert_eq!(scheduler.records()[0].counter, 12); + assert_eq!( + scheduler.records()[0].scalar_6.to_bits(), + 13.0_f32.to_bits() + ); + let positive_zero_key = handler_two_input(2.5, 0.0, 3.0, 12.0, 1); assert_eq!(scheduler.submit(positive_zero_key).index, 1); assert_eq!(scheduler.records().len(), 2); @@ -1272,8 +1283,13 @@ mod tests { let update = scheduler.submit(handler_two_input(f32::NAN, 1.0, 2.0, 4.0, 2)); assert_eq!(update.index, 0); assert!(update.refreshed); - assert_eq!(scheduler.records()[0].counter, 1); + assert_eq!(scheduler.records()[0].counter, u32::MAX - 1); assert_eq!(scheduler.records()[0].scalar_6.to_bits(), 4.0_f32.to_bits()); + + let update = scheduler.submit(handler_two_input(f32::NAN, 1.0, 2.0, 5.0, 17)); + assert!(update.refreshed); + assert_eq!(scheduler.records()[0].counter, u32::MAX - 2); + assert_eq!(scheduler.records()[0].scalar_6.to_bits(), 5.0_f32.to_bits()); } #[test] diff --git a/crates/fparkan-script/tests/handler2_native_oracle.cs b/crates/fparkan-script/tests/handler2_native_oracle.cs new file mode 100644 index 0000000..f632c68 --- /dev/null +++ b/crates/fparkan-script/tests/handler2_native_oracle.cs @@ -0,0 +1,87 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Security.Cryptography; +using System.Text; + +internal static class Handler2NativeOracle { + private const uint LoadWithAlteredSearchPath = 0x00000008; + private const int RefreshRecordRva = 0x5070; + private const string ExpectedSha256 = "c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc"; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr LoadLibraryEx(string fileName, IntPtr file, uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool FreeLibrary(IntPtr module); + + [UnmanagedFunctionPointer(CallingConvention.ThisCall)] + private delegate void RefreshRecord(IntPtr record); + + private static int Main() { + if (IntPtr.Size != 4) { + Console.Error.WriteLine("Build this oracle with an x86 target; ai.dll is 32-bit."); + return 2; + } + + string dllPath = Environment.GetEnvironmentVariable("FPARKAN_ORIGINAL_AI_DLL"); + if (String.IsNullOrEmpty(dllPath) || !File.Exists(dllPath)) { + Console.Error.WriteLine("Set FPARKAN_ORIGINAL_AI_DLL to the original GOG ai.dll."); + return 3; + } + + string actualSha256; + using (SHA256 sha256 = SHA256.Create()) + using (FileStream input = File.OpenRead(dllPath)) { + byte[] digest = sha256.ComputeHash(input); + StringBuilder hex = new StringBuilder(digest.Length * 2); + foreach (byte value in digest) hex.Append(value.ToString("x2")); + actualSha256 = hex.ToString(); + } + if (actualSha256 != ExpectedSha256) { + Console.Error.WriteLine("Unexpected ai.dll SHA-256: " + actualSha256); + return 4; + } + + IntPtr module = LoadLibraryEx(dllPath, IntPtr.Zero, LoadWithAlteredSearchPath); + if (module == IntPtr.Zero) { + Console.Error.WriteLine("LoadLibraryEx failed: " + Marshal.GetLastWin32Error()); + return 5; + } + + try { + var refresh = (RefreshRecord)Marshal.GetDelegateForFunctionPointer( + IntPtr.Add(module, RefreshRecordRva), typeof(RefreshRecord)); + IntPtr record = Marshal.AllocHGlobal(100); + try { + for (int offset = 0; offset < 100; offset += 4) Marshal.WriteInt32(record, offset, 0); + Marshal.WriteInt32(record, 0x24, 4); + Marshal.WriteInt32(record, 0x28, 4); + AssertRefresh(refresh, record, 8); + AssertRefresh(refresh, record, 12); + + Marshal.WriteInt32(record, 0x24, unchecked((int)UInt32.MaxValue)); + Marshal.WriteInt32(record, 0x28, unchecked((int)UInt32.MaxValue)); + AssertRefresh(refresh, record, UInt32.MaxValue - 1); + AssertRefresh(refresh, record, UInt32.MaxValue - 2); + } + finally { Marshal.FreeHGlobal(record); } + } + finally { FreeLibrary(module); } + return 0; + } + + private static void AssertRefresh(RefreshRecord refresh, IntPtr record, uint expectedCounter) { + uint savedIncrement = unchecked((uint)Marshal.ReadInt32(record, 0x28)); + refresh(record); + uint actualCounter = unchecked((uint)Marshal.ReadInt32(record, 0x24)); + uint actualIncrement = unchecked((uint)Marshal.ReadInt32(record, 0x28)); + Console.WriteLine("record[+0x24]=0x{0:X8} record[+0x28]=0x{1:X8}", + actualCounter, actualIncrement); + if (actualCounter != expectedCounter || actualIncrement != savedIncrement) { + throw new InvalidOperationException(String.Format( + "Expected +0x24=0x{0:X8}, +0x28 unchanged at 0x{1:X8}.", + expectedCounter, savedIncrement)); + } + } +} diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 64dc4fc..0e0217f 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -143,6 +143,43 @@ slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+ изолированную часть как `Handler2RecordScheduler`; он не выполняет bytecode, не назначает игровых имён и не делает event lookup за original VM. +Шаг прибавления проверен прямым вызовом оригинального helper-а +`FUN_10005070` (RVA `0x5070`) из GOG `ai.dll`, SHA-256 +`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`. +Исходник x86 test harness-а хранится в +[`handler2_native_oracle.cs`](../../crates/fparkan-script/tests/handler2_native_oracle.cs): +он сначала сверяет hash DLL, затем вызывает helper с 100-byte record и +проверяет оба поля. Пример воспроизведения в Windows PowerShell: + +```powershell +$env:FPARKAN_ORIGINAL_AI_DLL = 'C:\GOG Games\Parkan - Iron Strategy\ai.dll' +& "$env:WINDIR\Microsoft.NET\Framework\v4.0.30319\csc.exe" /nologo /platform:x86 /out:target\Handler2NativeOracle.exe crates\fparkan-script\tests\handler2_native_oracle.cs +.\target\Handler2NativeOracle.exe +``` + +Полученные значения: + +| `+0x24` до вызова | сохранённый `+0x28` | `+0x24` после вызова | `+0x28` после вызова | +| ---: | ---: | ---: | ---: | +| `0x00000004` | `0x00000004` | `0x00000008` | `0x00000004` | +| `0x00000008` | `0x00000004` | `0x0000000C` | `0x00000004` | +| `0xFFFFFFFF` | `0xFFFFFFFF` | `0xFFFFFFFE` | `0xFFFFFFFF` | +| `0xFFFFFFFE` | `0xFFFFFFFF` | `0xFFFFFFFD` | `0xFFFFFFFF` | + +Rust-тест повторяет эти начальные значения. Для первой пары обновлений входы +несут slot 2, равный `99` и `7`, однако сохранённый при создании шаг равен `4`; +для второй пары оба счётчика и шаг начинаются с `0xFFFFFFFF`, а новые входы +несут `2` и `17`. Правило refresh читается прямо из модели: + +```rust +record.counter = record.counter.wrapping_add(record.increment); +``` + +Native helper получает только указатель на record, поэтому напрямую проверяет +сложение текущего `+0x24` с сохранённым `+0x28`; свежий slot 2 принадлежит +сценарию Rust-теста, а не аргументам helper-а. Проверка не охватывает полный +scheduler path внутри запущенной игры. + На границе mission runtime выбранный TMA clan `first_resource` материализуется как отдельный `MissionScriptBundle`: loader нормализует `<base>.scr`, декодирует его тем же bounded reader-ом и публикует immutable |
