aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/fparkan-script/src/lib.rs235
-rw-r--r--docs/appendices/script-vm.md14
-rw-r--r--tools/ghidra/ExportAiVmHandler2SchedulerHelpers.java28
3 files changed, 274 insertions, 3 deletions
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<Handler2RecordInput> 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<Handler2Record>,
+}
+
+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
`<base>_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 нормализует
`<base>.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();
+ }
+}