aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-18 19:44:10 +0300
committerValentin Popov <valentin@popov.link>2026-07-18 19:44:10 +0300
commit50a74cc0a64352a50dd1023d48d0f94a4eca9627 (patch)
treefeb0ca78eb84a3e58515ac3ad043c6ce2a76517a
parentd31d1f8eab7a2c79e349748939de01895af5de7e (diff)
downloadfparkan-50a74cc0a64352a50dd1023d48d0f94a4eca9627.tar.xz
fparkan-50a74cc0a64352a50dd1023d48d0f94a4eca9627.zip
feat(script): classify VM dispatch selectors
-rw-r--r--apps/fparkan-cli/src/main.rs22
-rw-r--r--crates/fparkan-script/src/lib.rs58
-rw-r--r--docs/appendices/script-vm.md9
-rw-r--r--docs/tomes/06-behavior.md8
-rw-r--r--tools/ghidra/ExportAiVmHandler0.java23
-rw-r--r--tools/ghidra/ExportAiVmTableInstall.java23
6 files changed, 140 insertions, 3 deletions
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<ScriptHeaderWordCount>,
+}
+
+#[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::<u32, usize>::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<u32>,
}
+/// 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();
+ }
+}