diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs index 7bddb5c..185356f 100644 --- a/crates/fparkan-script/src/lib.rs +++ b/crates/fparkan-script/src/lib.rs @@ -67,6 +67,151 @@ pub enum ScriptDispatchSelector { Unknown(u32), } +/// Raw inputs resolved by the corpus-reachable `Handler(2)` before it reaches +/// the original event-record scheduler. +/// +/// The field names preserve handler slot order, not guessed gameplay meaning. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Handler2RecordInput { + /// Resolved slot 0 word. + pub word_0: u32, + /// Resolved slot 1 scalar. + pub scalar_1: f32, + /// Resolved slot 2 word. + pub word_2: u32, + /// Resolved slot 3 word. + pub word_3: u32, + /// Resolved slot 4 scalar. + pub scalar_4: f32, + /// Resolved slot 5 scalar. + pub scalar_5: f32, + /// Resolved slot 6 scalar. + pub scalar_6: f32, +} + +/// The exact three-word identity used by the original `Handler(2)` scheduler. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Handler2RecordKey { + /// First identity word from resolved slot 0. + pub word_0: u32, + /// IEEE-754 bits of resolved slot 4. + pub scalar_4_bits: u32, + /// IEEE-754 bits of resolved slot 5. + pub scalar_5_bits: u32, +} + +impl From for Handler2RecordKey { + fn from(input: Handler2RecordInput) -> Self { + Self { + word_0: input.word_0, + scalar_4_bits: input.scalar_4.to_bits(), + scalar_5_bits: input.scalar_5.to_bits(), + } + } +} + +/// A single backend-neutral event record created by `Handler(2)`. +/// +/// This mirrors only the fields whose construction and update rules are +/// statically recovered. Event-name lookup and the downstream consumer remain +/// separate runtime work. +#[derive(Clone, Debug, PartialEq)] +pub struct Handler2Record { + /// The three-word scheduler identity. + pub key: Handler2RecordKey, + /// Resolved slot 1 scalar. + pub scalar_1: f32, + /// Initial and per-refresh counter word from resolved slot 2. + pub counter: u32, + /// Resolved slot 3 word. + pub word_3: u32, + /// Resolved slot 6 scalar. + pub scalar_6: f32, +} + +/// The result of submitting one resolved `Handler(2)` record. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Handler2RecordUpdate { + /// Stable record position in insertion order. + pub index: usize, + /// Whether a new record was created. + pub created: bool, + /// Whether an existing record took the original refresh path. + pub refreshed: bool, +} + +/// Deterministic model of the original `Handler(2)` event-record collection. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Handler2RecordScheduler { + records: Vec, +} + +impl Handler2RecordScheduler { + /// Returns event records in original insertion order. + #[must_use] + pub fn records(&self) -> &[Handler2Record] { + &self.records + } + + /// Inserts or refreshes one resolved handler input. + /// + /// 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. + pub fn submit(&mut self, input: Handler2RecordInput) -> Handler2RecordUpdate { + let key = Handler2RecordKey::from(input); + if let Some((index, record)) = self + .records + .iter_mut() + .enumerate() + .find(|(_, record)| record.key == key) + { + 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); + return Handler2RecordUpdate { + index, + created: false, + refreshed: true, + }; + } + return Handler2RecordUpdate { + index, + created: false, + refreshed: false, + }; + } + let index = self.records.len(); + self.records.push(Handler2Record { + key, + scalar_1: input.scalar_1, + counter: input.word_2, + word_3: input.word_3, + scalar_6: input.scalar_6, + }); + Handler2RecordUpdate { + index, + created: true, + refreshed: false, + } + } +} + +fn handler_two_scalar_equal(left: f32, right: f32) -> bool { + if left.is_nan() || right.is_nan() { + return false; + } + let left_bits = left.to_bits(); + let right_bits = right.to_bits(); + left_bits == right_bits || (is_f32_zero_bits(left_bits) && is_f32_zero_bits(right_bits)) +} + +fn is_f32_zero_bits(bits: u32) -> bool { + matches!(bits, 0 | 0x8000_0000) +} + impl ScriptInstruction { /// Returns the recovered dispatch selector from the first disk word. #[must_use] @@ -206,10 +351,98 @@ fn read_instruction( #[cfg(test)] mod tests { use super::{ - decode, decode_with_limits, ScriptDispatchSelector, GOG_HANDLER_COUNT, INSTRUCTION_WORDS, + decode, decode_with_limits, Handler2RecordInput, Handler2RecordScheduler, + ScriptDispatchSelector, GOG_HANDLER_COUNT, INSTRUCTION_WORDS, }; use fparkan_binary::{DecodeError, Limits}; + fn handler_two_input( + scalar_1: f32, + scalar_4: f32, + scalar_5: f32, + scalar_6: f32, + word_2: u32, + ) -> Handler2RecordInput { + Handler2RecordInput { + word_0: 7, + scalar_1, + word_2, + word_3: 11, + scalar_4, + scalar_5, + scalar_6, + } + } + + #[test] + fn handler_two_scheduler_uses_three_word_bit_identity_and_refresh_contract() { + let mut scheduler = Handler2RecordScheduler::default(); + let first = handler_two_input(1.5, -0.0, 3.0, 9.0, 4); + assert_eq!( + scheduler.submit(first), + super::Handler2RecordUpdate { + index: 0, + created: true, + refreshed: false, + } + ); + assert_eq!(scheduler.records().len(), 1); + assert_eq!(scheduler.records()[0].counter, 4); + + let unchanged = handler_two_input(1.5, -0.0, 3.0, 12.0, 99); + assert_eq!( + scheduler.submit(unchanged), + super::Handler2RecordUpdate { + index: 0, + created: false, + refreshed: false, + } + ); + assert_eq!(scheduler.records()[0].counter, 4); + assert_eq!(scheduler.records()[0].scalar_6.to_bits(), 9.0_f32.to_bits()); + + let refreshed = handler_two_input(2.5, -0.0, 3.0, 12.0, 99); + assert_eq!( + scheduler.submit(refreshed), + super::Handler2RecordUpdate { + index: 0, + created: false, + refreshed: true, + } + ); + assert_eq!(scheduler.records()[0].counter, 103); + assert_eq!( + scheduler.records()[0].scalar_6.to_bits(), + 12.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); + } + + #[test] + fn handler_two_scheduler_refreshes_nan_and_wraps_counter() { + let mut scheduler = Handler2RecordScheduler::default(); + scheduler.submit(handler_two_input(f32::NAN, 1.0, 2.0, 3.0, u32::MAX)); + 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].scalar_6.to_bits(), 4.0_f32.to_bits()); + } + + #[test] + fn handler_two_scheduler_treats_signed_zero_value_as_unchanged() { + let mut scheduler = Handler2RecordScheduler::default(); + scheduler.submit(handler_two_input(-0.0, 1.0, 2.0, 3.0, 5)); + let update = scheduler.submit(handler_two_input(0.0, 1.0, 2.0, 4.0, 9)); + assert!(!update.created); + assert!(!update.refreshed); + assert_eq!(scheduler.records()[0].counter, 5); + assert_eq!(scheduler.records()[0].scalar_6.to_bits(), 3.0_f32.to_bits()); + } + #[test] fn decodes_lossless_event_and_instruction_records() { let mut bytes = Vec::new(); diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 8df602e..51da106 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -97,10 +97,20 @@ record fields `+0x0c` и `+0x14`, затем вызывает его refresh pat `_Continue`, сохраняет их IDs в indexed state и materializes новый internal record. В этой ветке не видно прямого World3D/Behavior call, поэтому это доказанная scheduler/event-record boundary, а не команда движения, атаки -или строительства. Semantic names семи slots, key equality и consumer нового -record остаются открытыми; до dynamic capture Rust возвращает явный +или строительства. Semantic names семи slots и consumer нового record остаются +открытыми; до dynamic capture Rust возвращает явный unsupported result, а не «примерный» game command. +Следующий static pass закрывает equality/update policy. Identity ровно равна +`(slot0 word, slot4 IEEE-754 bits, slot5 IEEE-754 bits)`, поэтому `-0.0` и +`+0.0` различаются. Новый 100-byte record получает slot1 в поле `+0x14`, +slot2 одновременно в `+0x24/+0x28`, slot3 в `+0x2c` и slot6 в `+0x0c`. При +совпавшем key refresh случается только когда slot1 сравнивается unequal +(включая NaN); он заменяет `+0x14` и `+0x0c`, затем прибавляет сохранённый +`+0x28` к `+0x24` с x86 wrapping arithmetic. `fparkan-script` отражает эту +изолированную часть как `Handler2RecordScheduler`; он не выполняет bytecode, +не назначает игровых имён и не делает event lookup за original VM. + На границе mission runtime выбранный TMA clan `first_resource` теперь материализуется как отдельный `MissionScriptBundle`: loader нормализует `.scr`, декодирует его тем же bounded reader-ом и публикует immutable diff --git a/tools/ghidra/ExportAiVmHandler2SchedulerHelpers.java b/tools/ghidra/ExportAiVmHandler2SchedulerHelpers.java new file mode 100644 index 0000000..76b335d --- /dev/null +++ b/tools/ghidra/ExportAiVmHandler2SchedulerHelpers.java @@ -0,0 +1,28 @@ +// Emits record construction, equality, refresh and insertion helpers called by +// the corpus-reachable AI VM Handler(2) scheduler boundary. +// Run through Ghidra headless analysis; the original PE remains read only. +import ghidra.app.decompiler.DecompInterface; +import ghidra.app.script.GhidraScript; +import ghidra.program.model.address.Address; +import ghidra.program.model.listing.Function; + +public class ExportAiVmHandler2SchedulerHelpers extends GhidraScript { + private static final long[] ADDRESSES = { + 0x10004e50L, 0x10004c50L, 0x10005070L, 0x100073e0L + }; + + @Override + public void run() throws Exception { + DecompInterface decompiler = new DecompInterface(); + decompiler.openProgram(currentProgram); + for (long value : ADDRESSES) { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress(value); + Function function = currentProgram.getFunctionManager().getFunctionAt(address); + println("===== AI Handler(2) scheduler helper " + address + " ====="); + if (function == null) { println("missing"); continue; } + println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC()); + } + decompiler.dispose(); + } +}