From 50a74cc0a64352a50dd1023d48d0f94a4eca9627 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 20:44:10 +0400 Subject: [PATCH] feat(script): classify VM dispatch selectors --- apps/fparkan-cli/src/main.rs | 22 ++++++++- crates/fparkan-script/src/lib.rs | 58 +++++++++++++++++++++++- docs/appendices/script-vm.md | 9 ++++ docs/tomes/06-behavior.md | 8 ++++ tools/ghidra/ExportAiVmHandler0.java | 23 ++++++++++ tools/ghidra/ExportAiVmTableInstall.java | 23 ++++++++++ 6 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 tools/ghidra/ExportAiVmHandler0.java create mode 100644 tools/ghidra/ExportAiVmTableInstall.java diff --git a/apps/fparkan-cli/src/main.rs b/apps/fparkan-cli/src/main.rs index a6fb25e..dbaf5dd 100644 --- a/apps/fparkan-cli/src/main.rs +++ b/apps/fparkan-cli/src/main.rs @@ -48,7 +48,7 @@ const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1"; const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1"; const MODEL_INSPECT_SCHEMA: &str = "fparkan-model-inspect-v1"; const WEAR_INSPECT_SCHEMA: &str = "fparkan-wear-inspect-v1"; -const SCRIPT_INSPECT_SCHEMA: &str = "fparkan-script-inspect-v1"; +const SCRIPT_INSPECT_SCHEMA: &str = "fparkan-script-inspect-v2"; #[derive(Serialize)] struct ArchiveInspectOutput<'a> { @@ -236,6 +236,13 @@ struct ScriptInspectOutput<'a> { instructions: usize, references: usize, trailing_bytes: usize, + first_header_word_candidates: Vec, +} + +#[derive(Serialize)] +struct ScriptHeaderWordCount { + value: u32, + instructions: usize, } fn main() { @@ -335,6 +342,10 @@ fn script_inspect_json( .flat_map(|event| &event.instructions) .map(|instruction| instruction.references.len()) .sum(); + let mut candidates = std::collections::BTreeMap::::new(); + for instruction in package.events.iter().flat_map(|event| &event.instructions) { + *candidates.entry(instruction.header_words[0]).or_insert(0) += 1; + } serialize_json(&ScriptInspectOutput { schema_version: SCRIPT_INSPECT_SCHEMA, path, @@ -343,6 +354,13 @@ fn script_inspect_json( instructions, references, trailing_bytes: package.trailing_bytes.len(), + first_header_word_candidates: candidates + .into_iter() + .map(|(value, instructions)| ScriptHeaderWordCount { + value, + instructions, + }) + .collect(), }) } @@ -856,7 +874,7 @@ mod tests { fparkan_script::decode(&[73, 0, 0, 0, 0, 0, 0, 0]).expect("minimal script package"); assert_eq!( script_inspect_json("script.scr", &package), - Ok("{\"schema_version\":\"fparkan-script-inspect-v1\",\"path\":\"script.scr\",\"opcode_handler_count\":73,\"events\":0,\"instructions\":0,\"references\":0,\"trailing_bytes\":0}".to_string()) + Ok("{\"schema_version\":\"fparkan-script-inspect-v2\",\"path\":\"script.scr\",\"opcode_handler_count\":73,\"events\":0,\"instructions\":0,\"references\":0,\"trailing_bytes\":0,\"first_header_word_candidates\":[]}".to_string()) ); } diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs index 6aa8415..7bddb5c 100644 --- a/crates/fparkan-script/src/lib.rs +++ b/crates/fparkan-script/src/lib.rs @@ -11,6 +11,7 @@ use std::sync::Arc; const INSTRUCTION_HEADER_BYTES: u64 = 28; const INSTRUCTION_WORDS: usize = 7; +const GOG_HANDLER_COUNT: u32 = 73; /// A compiled `.scr` package. #[derive(Clone, Debug, Eq, PartialEq)] @@ -50,6 +51,38 @@ pub struct ScriptInstruction { pub references: Vec, } +/// The recovered selector for an instruction's installed handler table. +/// +/// The GOG AI loader copies 73 function pointers in order. Across all checked +/// GOG packages the first disk word is either one of these indices or the +/// explicit `u32::MAX` sentinel. This is a disassembly contract, not an +/// instruction executor. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ScriptDispatchSelector { + /// One of the ordered handlers installed by `ai.dll`. + Handler(u8), + /// The on-disk `0xffff_ffff` sentinel. + Sentinel, + /// A value not yet observed or accepted by the GOG handler table. + Unknown(u32), +} + +impl ScriptInstruction { + /// Returns the recovered dispatch selector from the first disk word. + #[must_use] + pub fn dispatch_selector(&self) -> ScriptDispatchSelector { + match self.header_words[0] { + value if value < GOG_HANDLER_COUNT => + { + #[allow(clippy::cast_possible_truncation)] + ScriptDispatchSelector::Handler(self.header_words[0] as u8) + } + u32::MAX => ScriptDispatchSelector::Sentinel, + value => ScriptDispatchSelector::Unknown(value), + } + } +} + /// Decodes a compiled AI package using default safety limits. /// /// # Errors @@ -172,7 +205,9 @@ fn read_instruction( #[cfg(test)] mod tests { - use super::{decode, decode_with_limits}; + use super::{ + decode, decode_with_limits, ScriptDispatchSelector, GOG_HANDLER_COUNT, INSTRUCTION_WORDS, + }; use fparkan_binary::{DecodeError, Limits}; #[test] @@ -203,6 +238,10 @@ mod tests { [1, 2, 3, 4, 5, 2, 6] ); assert_eq!(package.events[0].instructions[0].references, [7, 8]); + assert_eq!( + package.events[0].instructions[0].dispatch_selector(), + ScriptDispatchSelector::Handler(1) + ); assert_eq!(package.trailing_bytes, [0xaa, 0xbb]); } @@ -251,4 +290,21 @@ mod tests { Err(DecodeError::LimitExceeded { .. }) )); } + + #[test] + fn dispatch_selector_preserves_sentinel_and_unobserved_values() { + let mut instruction = super::ScriptInstruction { + header_words: [u32::MAX; INSTRUCTION_WORDS], + references: Vec::new(), + }; + assert_eq!( + instruction.dispatch_selector(), + ScriptDispatchSelector::Sentinel + ); + instruction.header_words[0] = GOG_HANDLER_COUNT; + assert_eq!( + instruction.dispatch_selector(), + ScriptDispatchSelector::Unknown(GOG_HANDLER_COUNT) + ); + } } diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 128acd9..6836f82 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -53,6 +53,15 @@ cargo run -p fparkan-cli -- script inspect ` records, 20 references и 0 trailing bytes. Это corpus evidence для reader-а, но не разрешение на исполнение неизвестных 73 opcodes. +Теперь установлен selector: loader `0x10001000` создаёт 73 pointers в +фиксированном порядке, а `0x10011e70` копирует их без перестановки в runtime +array. Во всех 58 GOG `.scr` первый header word каждого nested record равен +`0..72` либо `0xffff_ffff`: соответственно 2095 handler selectors и 3992 +sentinel records. Поэтому `ScriptInstruction::dispatch_selector()` возвращает +`Handler(0..72)`, `Sentinel` или сохраняемый `Unknown(u32)`. Первый handler +(`Handler(0)`, VA `0x10008034`) только устанавливает current context и flag +`+0x50 = 1`; это не даёт ему игрового имени и не заменяет runtime trace. + TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state, diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md index 5e0299e..ad1832c 100644 --- a/docs/tomes/06-behavior.md +++ b/docs/tomes/06-behavior.md @@ -143,6 +143,14 @@ references каждого record, жёстко ограничивает counts/a файла, но не таблица семантик: названия opcode/words появятся только после handler contracts и runtime traces. +Связь первого header word с dispatch теперь доказана статически: `ai.dll` +создаёт 73 handler pointers в известном порядке и копирует table без +перестановки. По всем 58 GOG packages первый word — индекс `0..72` либо +`0xffff_ffff` sentinel; `fparkan-script` отражает это как typed +`ScriptDispatchSelector`, сохраняя неожиданные значения `Unknown`. Первый +handler лишь инициирует execution context (`+0x50 = 1`); его gameplay meaning +пока не назван. + Безопасная runtime-модель: ```text diff --git a/tools/ghidra/ExportAiVmHandler0.java b/tools/ghidra/ExportAiVmHandler0.java new file mode 100644 index 0000000..43e8046 --- /dev/null +++ b/tools/ghidra/ExportAiVmHandler0.java @@ -0,0 +1,23 @@ +// Emits the first function in the AI DLL's verified 73-entry VM handler table. +// 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 ExportAiVmHandler0 extends GhidraScript { + private static final long ADDRESS = 0x10008034L; + + @Override + public void run() throws Exception { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress(ADDRESS); + Function function = currentProgram.getFunctionManager().getFunctionAt(address); + println("===== AI VM handler 0 ====="); + if (function == null) { println("missing"); return; } + DecompInterface decompiler = new DecompInterface(); + decompiler.openProgram(currentProgram); + println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC()); + decompiler.dispose(); + } +} diff --git a/tools/ghidra/ExportAiVmTableInstall.java b/tools/ghidra/ExportAiVmTableInstall.java new file mode 100644 index 0000000..842e7dc --- /dev/null +++ b/tools/ghidra/ExportAiVmTableInstall.java @@ -0,0 +1,23 @@ +// Emits the routine that receives the AI VM's verified 73-entry handler table. +// 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 ExportAiVmTableInstall extends GhidraScript { + private static final long ADDRESS = 0x10011E70L; + + @Override + public void run() throws Exception { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress(ADDRESS); + Function function = currentProgram.getFunctionManager().getFunctionAt(address); + println("===== AI VM handler table install ====="); + if (function == null) { println("missing"); return; } + DecompInterface decompiler = new DecompInterface(); + decompiler.openProgram(currentProgram); + println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC()); + decompiler.dispose(); + } +}