aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock8
-rw-r--r--Cargo.toml1
-rw-r--r--apps/fparkan-cli/Cargo.toml1
-rw-r--r--apps/fparkan-cli/src/main.rs64
-rw-r--r--crates/fparkan-script/Cargo.toml12
-rw-r--r--crates/fparkan-script/src/lib.rs254
-rw-r--r--docs/appendices/script-vm.md27
-rw-r--r--docs/tomes/06-behavior.md10
-rw-r--r--fixtures/acceptance/stage_packages.toml1
-rw-r--r--tools/ghidra/ExportAiScriptLoader.java23
-rw-r--r--tools/ghidra/ExportAiScriptPackageReader.java23
-rw-r--r--tools/ghidra/FindAiScriptLoaderReferences.java29
12 files changed, 449 insertions, 4 deletions
diff --git a/Cargo.lock b/Cargo.lock
index 5237da5..065741e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -404,6 +404,7 @@ dependencies = [
"fparkan-prototype",
"fparkan-resource",
"fparkan-runtime",
+ "fparkan-script",
"fparkan-vfs",
"serde",
"serde_json",
@@ -613,6 +614,13 @@ dependencies = [
]
[[package]]
+name = "fparkan-script"
+version = "0.1.0"
+dependencies = [
+ "fparkan-binary",
+]
+
+[[package]]
name = "fparkan-terrain"
version = "0.1.0"
dependencies = [
diff --git a/Cargo.toml b/Cargo.toml
index 3e92b8a..5e9d546 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -18,6 +18,7 @@ members = [
"crates/fparkan-render",
"crates/fparkan-resource",
"crates/fparkan-rsli",
+ "crates/fparkan-script",
"crates/fparkan-runtime",
"crates/fparkan-terrain",
"crates/fparkan-terrain-format",
diff --git a/apps/fparkan-cli/Cargo.toml b/apps/fparkan-cli/Cargo.toml
index 10689b4..dcf9c12 100644
--- a/apps/fparkan-cli/Cargo.toml
+++ b/apps/fparkan-cli/Cargo.toml
@@ -13,6 +13,7 @@ fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.
fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
fparkan-resource = { path = "../../crates/fparkan-resource", version = "0.1.0" }
fparkan-runtime = { path = "../../crates/fparkan-runtime", version = "0.1.0" }
+fparkan-script = { path = "../../crates/fparkan-script", version = "0.1.0" }
fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
diff --git a/apps/fparkan-cli/src/main.rs b/apps/fparkan-cli/src/main.rs
index 2f933e6..a6fb25e 100644
--- a/apps/fparkan-cli/src/main.rs
+++ b/apps/fparkan-cli/src/main.rs
@@ -48,6 +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";
#[derive(Serialize)]
struct ArchiveInspectOutput<'a> {
@@ -226,6 +227,17 @@ struct PrototypeGraphEdgeInspectOutput {
resource_raw_hex: Option<String>,
}
+#[derive(Serialize)]
+struct ScriptInspectOutput<'a> {
+ schema_version: &'static str,
+ path: &'a str,
+ opcode_handler_count: u32,
+ events: usize,
+ instructions: usize,
+ references: usize,
+ trailing_bytes: usize,
+}
+
fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
let result = run(&args);
@@ -290,10 +302,50 @@ fn run(args: &[String]) -> Result<(), String> {
let rest = strip_format_json(rest)?;
inspect_model(&rest)
}
+ [domain, command, rest @ ..] if domain == "script" && command == "inspect" => {
+ let rest = strip_format_json(rest)?;
+ inspect_script(&rest)
+ }
_ => Err(usage()),
}
}
+fn inspect_script(args: &[String]) -> Result<(), String> {
+ let path = parse_file_path(args, "script inspect")?;
+ let bytes = std::fs::read(&path).map_err(|err| format!("{}: {err}", path.display()))?;
+ let package =
+ fparkan_script::decode(&bytes).map_err(|err| format!("{}: {err}", path.display()))?;
+ let display_path = path.display().to_string();
+ println!("{}", script_inspect_json(&display_path, &package)?);
+ Ok(())
+}
+
+fn script_inspect_json(
+ path: &str,
+ package: &fparkan_script::ScriptPackage,
+) -> Result<String, String> {
+ let instructions = package
+ .events
+ .iter()
+ .map(|event| event.instructions.len())
+ .sum();
+ let references = package
+ .events
+ .iter()
+ .flat_map(|event| &event.instructions)
+ .map(|instruction| instruction.references.len())
+ .sum();
+ serialize_json(&ScriptInspectOutput {
+ schema_version: SCRIPT_INSPECT_SCHEMA,
+ path,
+ opcode_handler_count: package.opcode_handler_count,
+ events: package.events.len(),
+ instructions,
+ references,
+ trailing_bytes: package.trailing_bytes.len(),
+ })
+}
+
fn exit_code(result: &Result<(), String>) -> i32 {
if result.is_ok() {
0
@@ -769,7 +821,7 @@ fn prototype_graph_requiredness_label(
}
fn usage() -> String {
- "usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | terrain inspect <Land.msh> [--format json] | wear inspect --root <path> --archive <archive> --resource <wear.wea> [--format json] | model inspect --root <path> --archive <archive> --resource <model.msh> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph|inspect --root <path> --mission <path> [--format json]".to_string()
+ "usage: fparkan corpus discover|validate --root <path> [--format json] | archive inspect <file> [--format json] | terrain inspect <Land.msh> [--format json] | wear inspect --root <path> --archive <archive> --resource <wear.wea> [--format json] | model inspect --root <path> --archive <archive> --resource <model.msh> [--format json] | script inspect <file> [--format json] | prototype inspect --root <path> --key <key> [--format json] | mission graph|inspect --root <path> --mission <path> [--format json]".to_string()
}
#[cfg(test)]
@@ -799,6 +851,16 @@ mod tests {
}
#[test]
+ fn script_inspect_json_has_canonical_field_order() {
+ let package =
+ 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())
+ );
+ }
+
+ #[test]
fn archive_json_has_schema_version() {
let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true))
.expect("serialize archive inspection");
diff --git a/crates/fparkan-script/Cargo.toml b/crates/fparkan-script/Cargo.toml
new file mode 100644
index 0000000..0280be7
--- /dev/null
+++ b/crates/fparkan-script/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "fparkan-script"
+version.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+
+[dependencies]
+fparkan-binary = { path = "../fparkan-binary", version = "0.1.0" }
+
+[lints]
+workspace = true
diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs
new file mode 100644
index 0000000..6aa8415
--- /dev/null
+++ b/crates/fparkan-script/src/lib.rs
@@ -0,0 +1,254 @@
+#![forbid(unsafe_code)]
+#![cfg_attr(test, allow(clippy::expect_used, clippy::panic, clippy::unwrap_used))]
+//! Lossless, bounded reader for compiled AI `.scr` packages.
+//!
+//! This module preserves the layout proven by the GOG `ai.dll` reader. It
+//! deliberately does not assign semantics to instruction words or execute
+//! bytecode: that requires handler-specific evidence.
+
+use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits};
+use std::sync::Arc;
+
+const INSTRUCTION_HEADER_BYTES: u64 = 28;
+const INSTRUCTION_WORDS: usize = 7;
+
+/// A compiled `.scr` package.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ScriptPackage {
+ /// Number of opcode handlers expected by the package.
+ pub opcode_handler_count: u32,
+ /// Named events in original file order.
+ pub events: Vec<ScriptEvent>,
+ /// Bytes not consumed by the recovered framing.
+ pub trailing_bytes: Vec<u8>,
+ /// Original package bytes.
+ pub raw: Arc<[u8]>,
+}
+
+/// One named event record.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ScriptEvent {
+ /// Declared byte count excluding the NUL terminator.
+ pub name_len: u32,
+ /// Name bytes including its mandatory NUL terminator.
+ pub name_raw: Vec<u8>,
+ /// Opaque event word following the name.
+ pub event_word: u32,
+ /// Nested instruction records in original file order.
+ pub instructions: Vec<ScriptInstruction>,
+}
+
+/// A lossless instruction record.
+///
+/// Seven header words are retained in their on-disk order. The sixth word
+/// declares the number of following references; the seventh follows them.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ScriptInstruction {
+ /// Opaque header words in original file order.
+ pub header_words: [u32; INSTRUCTION_WORDS],
+ /// References stored after header word five and before word six.
+ pub references: Vec<u32>,
+}
+
+/// Decodes a compiled AI package using default safety limits.
+///
+/// # Errors
+///
+/// Returns a bounded [`DecodeError`] on truncated or oversized input.
+pub fn decode(bytes: &[u8]) -> Result<ScriptPackage, DecodeError> {
+ decode_with_limits(bytes, Limits::default())
+}
+
+/// Decodes a compiled AI package using explicit safety limits.
+///
+/// # Errors
+///
+/// Returns a bounded [`DecodeError`] on truncated or oversized input.
+pub fn decode_with_limits(bytes: &[u8], limits: Limits) -> Result<ScriptPackage, DecodeError> {
+ if u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)? > limits.max_file_bytes
+ {
+ return Err(DecodeError::LimitExceeded {
+ count: u64::try_from(bytes.len()).map_err(|_| DecodeError::IntegerOverflow)?,
+ limit: limits.max_file_bytes,
+ });
+ }
+ let mut cursor = Cursor::new(bytes);
+ let opcode_handler_count = cursor.read_u32_le()?;
+ let event_count = cursor.read_u32_le()?;
+ checked_allocation_len(u64::from(event_count), u64::from(limits.max_entries))?;
+ let mut events =
+ Vec::with_capacity(usize::try_from(event_count).map_err(|_| DecodeError::IntegerOverflow)?);
+ for _ in 0..event_count {
+ events.push(read_event(&mut cursor, limits)?);
+ }
+ let trailing_bytes = cursor.read_exact(cursor.remaining())?.to_vec();
+ Ok(ScriptPackage {
+ opcode_handler_count,
+ events,
+ trailing_bytes,
+ raw: Arc::from(bytes),
+ })
+}
+
+fn read_event(cursor: &mut Cursor<'_>, limits: Limits) -> Result<ScriptEvent, DecodeError> {
+ let name_len = cursor.read_u32_le()?;
+ let name_bytes = u64::from(name_len)
+ .checked_add(1)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ let name_len_usize = checked_allocation_len(name_bytes, u64::from(limits.max_string_bytes))?;
+ let name_raw = cursor.read_exact(name_len_usize)?.to_vec();
+ if name_raw.last().copied() != Some(0) {
+ return Err(DecodeError::Invalid(
+ "script event name is not NUL terminated",
+ ));
+ }
+ let event_word = cursor.read_u32_le()?;
+ let instruction_count = cursor.read_u32_le()?;
+ checked_allocation_len(u64::from(instruction_count), u64::from(limits.max_entries))?;
+ let minimum = u64::from(instruction_count)
+ .checked_mul(INSTRUCTION_HEADER_BYTES)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ if minimum > u64::try_from(cursor.remaining()).map_err(|_| DecodeError::IntegerOverflow)? {
+ return Err(DecodeError::UnexpectedEof {
+ offset: cursor.offset(),
+ needed: minimum,
+ remaining: u64::try_from(cursor.remaining())
+ .map_err(|_| DecodeError::IntegerOverflow)?,
+ });
+ }
+ let mut instructions = Vec::with_capacity(
+ usize::try_from(instruction_count).map_err(|_| DecodeError::IntegerOverflow)?,
+ );
+ for _ in 0..instruction_count {
+ instructions.push(read_instruction(cursor, limits)?);
+ }
+ Ok(ScriptEvent {
+ name_len,
+ name_raw,
+ event_word,
+ instructions,
+ })
+}
+
+fn read_instruction(
+ cursor: &mut Cursor<'_>,
+ limits: Limits,
+) -> Result<ScriptInstruction, DecodeError> {
+ let mut header_words = [0; INSTRUCTION_WORDS];
+ for word in &mut header_words[..5] {
+ *word = cursor.read_u32_le()?;
+ }
+ header_words[5] = cursor.read_u32_le()?;
+ let reference_count = header_words[5];
+ let reference_bytes = u64::from(reference_count)
+ .checked_mul(4)
+ .ok_or(DecodeError::IntegerOverflow)?;
+ if reference_bytes
+ > u64::try_from(cursor.remaining()).map_err(|_| DecodeError::IntegerOverflow)?
+ {
+ return Err(DecodeError::UnexpectedEof {
+ offset: cursor.offset(),
+ needed: reference_bytes,
+ remaining: u64::try_from(cursor.remaining())
+ .map_err(|_| DecodeError::IntegerOverflow)?,
+ });
+ }
+ checked_allocation_len(
+ u64::from(reference_count),
+ u64::from(limits.max_array_items),
+ )?;
+ let mut references = Vec::with_capacity(
+ usize::try_from(reference_count).map_err(|_| DecodeError::IntegerOverflow)?,
+ );
+ for _ in 0..reference_count {
+ references.push(cursor.read_u32_le()?);
+ }
+ header_words[6] = cursor.read_u32_le()?;
+ Ok(ScriptInstruction {
+ header_words,
+ references,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{decode, decode_with_limits};
+ use fparkan_binary::{DecodeError, Limits};
+
+ #[test]
+ fn decodes_lossless_event_and_instruction_records() {
+ let mut bytes = Vec::new();
+ bytes.extend_from_slice(&73_u32.to_le_bytes());
+ bytes.extend_from_slice(&1_u32.to_le_bytes());
+ bytes.extend_from_slice(&4_u32.to_le_bytes());
+ bytes.extend_from_slice(b"Init\0");
+ bytes.extend_from_slice(&9_u32.to_le_bytes());
+ bytes.extend_from_slice(&1_u32.to_le_bytes());
+ for word in [1_u32, 2, 3, 4, 5, 2] {
+ bytes.extend_from_slice(&word.to_le_bytes());
+ }
+ for reference in [7_u32, 8] {
+ bytes.extend_from_slice(&reference.to_le_bytes());
+ }
+ bytes.extend_from_slice(&6_u32.to_le_bytes());
+ bytes.extend_from_slice(&[0xaa, 0xbb]);
+
+ let package = decode(&bytes).expect("valid script package");
+ assert_eq!(package.opcode_handler_count, 73);
+ assert_eq!(package.events.len(), 1);
+ assert_eq!(package.events[0].name_raw, b"Init\0");
+ assert_eq!(package.events[0].event_word, 9);
+ assert_eq!(
+ package.events[0].instructions[0].header_words,
+ [1, 2, 3, 4, 5, 2, 6]
+ );
+ assert_eq!(package.events[0].instructions[0].references, [7, 8]);
+ assert_eq!(package.trailing_bytes, [0xaa, 0xbb]);
+ }
+
+ #[test]
+ fn rejects_missing_event_nul_and_truncated_references() {
+ let mut missing_nul = Vec::new();
+ missing_nul.extend_from_slice(&0_u32.to_le_bytes());
+ missing_nul.extend_from_slice(&1_u32.to_le_bytes());
+ missing_nul.extend_from_slice(&1_u32.to_le_bytes());
+ missing_nul.extend_from_slice(b"AB");
+ assert_eq!(
+ decode(&missing_nul),
+ Err(DecodeError::Invalid(
+ "script event name is not NUL terminated"
+ ))
+ );
+
+ let mut truncated = Vec::new();
+ truncated.extend_from_slice(&0_u32.to_le_bytes());
+ truncated.extend_from_slice(&1_u32.to_le_bytes());
+ truncated.extend_from_slice(&0_u32.to_le_bytes());
+ truncated.push(0);
+ truncated.extend_from_slice(&0_u32.to_le_bytes());
+ truncated.extend_from_slice(&1_u32.to_le_bytes());
+ for word in [0_u32, 0, 0, 0, 0, 1] {
+ truncated.extend_from_slice(&word.to_le_bytes());
+ }
+ assert!(matches!(
+ decode(&truncated),
+ Err(DecodeError::UnexpectedEof { .. })
+ ));
+ }
+
+ #[test]
+ fn explicit_limits_bound_event_allocations() {
+ let bytes = [0_u8; 8];
+ let limits = Limits {
+ max_entries: 0,
+ ..Limits::default()
+ };
+ assert!(decode_with_limits(&bytes, limits).is_ok());
+
+ let bytes = [0_u8, 0, 0, 0, 1, 0, 0, 0];
+ assert!(matches!(
+ decode_with_limits(&bytes, limits),
+ Err(DecodeError::LimitExceeded { .. })
+ ));
+ }
+}
diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md
index 5e6adb8..128acd9 100644
--- a/docs/appendices/script-vm.md
+++ b/docs/appendices/script-vm.md
@@ -10,9 +10,16 @@ messages, teleports, задачи, research и campaign transitions. Точки
и `briefing.cfg`.
`.scr` — binary package с version checks, symbol/event sections и offsets;
-полная opcode grammar не доказана. `.fml` — текстовый symbol/formula oracle;
-`varset.var` задаёт `VAR(...)`/`STRING(...)` defaults; `.trf` — NRes tables,
-чей framing подтверждён, а field semantics местами лишь consumer-inferred.
+полная opcode grammar не доказана. Его внешний framing теперь читает
+`fparkan-script`: первый little-endian `u32` является числом required opcode
+handlers, второй — числом event records. Каждый event хранит `name_len`,
+`name_len + 1` raw bytes с обязательным NUL, opaque event word и count вложенных
+records. Вложенный record сохраняет семь `u32` header words (в disk order),
+список `u32` references после шестого header word и trailing seventh word.
+Никакой из этих words ещё не получает semantic name. `.fml` — текстовый
+symbol/formula oracle; `varset.var` задаёт `VAR(...)`/`STRING(...)` defaults;
+`.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь
+consumer-inferred.
## Безопасная модель исполнения
@@ -32,6 +39,20 @@ instruction dispatcher или jump table `.scr`: bytecode opcode table всё е
byte: это ломает синхронизацию. Для каждого доказанного opcode фиксируются
number, size, operands, control flow, effects, errors и минимальный test.
+GOG `ai.dll` доказывает этот framing двумя consumer-ами: loader по
+`0x10001000` открывает `<bundle>.scr`, `varset.var`, `<bundle>.fml`, затем
+собирает ровно 73 pointers handlers; `0x10011b20` читает описанную count-driven
+структуру. Команда
+
+```powershell
+cargo run -p fparkan-cli -- script inspect `
+ 'C:\GOG Games\Parkan - Iron Strategy\MISSIONS\SCRIPTS\c1m2p.scr' --format json
+```
+
+на исходном пакете возвращает `opcode_handler_count=73`, 9 events, 17 nested
+records, 20 references и 0 trailing bytes. Это corpus evidence для reader-а,
+но не разрешение на исполнение неизвестных 73 opcodes.
+
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 7f825ad..5e0299e 100644
--- a/docs/tomes/06-behavior.md
+++ b/docs/tomes/06-behavior.md
@@ -133,6 +133,16 @@ Wizard получает желаемое направление и corridor, а
описывает типы, defaults, ranges и строки через макросоподобные формы
`VAR(...)` и `STRING(...)`.
+Compiled package больше не opaque blob: `fparkan-script` losslessly читает
+проверенный внешний framing. Сначала идут `opcode_handler_count` и
+`event_count` (`u32 LE`), затем именованные события с NUL-terminated raw
+именем и nested records. Reader сохраняет все seven raw header words и
+references каждого record, жёстко ограничивает counts/allocations и отдельно
+сохраняет trailing bytes. На `c1m2p.scr` GOG reader получает `73` handlers,
+`9` events, `17` records и `20` references без trailing bytes. Это структура
+файла, но не таблица семантик: названия opcode/words появятся только после
+handler contracts и runtime traces.
+
Безопасная runtime-модель:
```text
diff --git a/fixtures/acceptance/stage_packages.toml b/fixtures/acceptance/stage_packages.toml
index 9a0faaa..5b0845c 100644
--- a/fixtures/acceptance/stage_packages.toml
+++ b/fixtures/acceptance/stage_packages.toml
@@ -35,6 +35,7 @@ schema = 1
"4" = [
"fparkan-animation",
"fparkan-fx",
+ "fparkan-script",
]
"5" = [
"fparkan-game",
diff --git a/tools/ghidra/ExportAiScriptLoader.java b/tools/ghidra/ExportAiScriptLoader.java
new file mode 100644
index 0000000..1b457d4
--- /dev/null
+++ b/tools/ghidra/ExportAiScriptLoader.java
@@ -0,0 +1,23 @@
+// Emits the GOG AI script-bundle loader, discovered from references to
+// "MISSIONS\\SCRIPTS\\" and ".scr". Run headless; original input stays 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 ExportAiScriptLoader extends GhidraScript {
+ private static final long ADDRESS = 0x10001000L;
+
+ @Override
+ public void run() throws Exception {
+ Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
+ .getAddress(ADDRESS);
+ Function function = currentProgram.getFunctionManager().getFunctionAt(address);
+ println("===== AI script loader =====");
+ 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/ExportAiScriptPackageReader.java b/tools/ghidra/ExportAiScriptPackageReader.java
new file mode 100644
index 0000000..9365165
--- /dev/null
+++ b/tools/ghidra/ExportAiScriptPackageReader.java
@@ -0,0 +1,23 @@
+// Emits the immediate .scr package reader called by the AI script loader.
+// Run through Ghidra headless analysis; the original PE is never modified.
+import ghidra.app.decompiler.DecompInterface;
+import ghidra.app.script.GhidraScript;
+import ghidra.program.model.address.Address;
+import ghidra.program.model.listing.Function;
+
+public class ExportAiScriptPackageReader extends GhidraScript {
+ private static final long ADDRESS = 0x10011B20L;
+
+ @Override
+ public void run() throws Exception {
+ Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
+ .getAddress(ADDRESS);
+ Function function = currentProgram.getFunctionManager().getFunctionAt(address);
+ println("===== AI .scr package reader =====");
+ 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/FindAiScriptLoaderReferences.java b/tools/ghidra/FindAiScriptLoaderReferences.java
new file mode 100644
index 0000000..5aae152
--- /dev/null
+++ b/tools/ghidra/FindAiScriptLoaderReferences.java
@@ -0,0 +1,29 @@
+// Locates callers that reference the stable AI script-loader literals.
+// Run through Ghidra headless analysis; the original PE is read only.
+import ghidra.app.script.GhidraScript;
+import ghidra.program.model.address.Address;
+import ghidra.program.model.listing.Function;
+import ghidra.program.model.mem.Memory;
+import ghidra.program.model.symbol.Reference;
+import ghidra.program.model.symbol.ReferenceManager;
+
+public class FindAiScriptLoaderReferences extends GhidraScript {
+ private static final String[] NEEDLES = {".scr", "MISSIONS\\SCRIPTS\\"};
+
+ @Override
+ public void run() throws Exception {
+ Memory memory = currentProgram.getMemory();
+ ReferenceManager references = currentProgram.getReferenceManager();
+ for (String needle : NEEDLES) {
+ byte[] bytes = (needle + "\0").getBytes("US-ASCII");
+ Address address = memory.findBytes(memory.getMinAddress(), memory.getMaxAddress(), bytes, null, true, monitor);
+ println("===== " + needle + " =====");
+ if (address == null) { println("missing"); continue; }
+ println("literal=" + address);
+ for (Reference reference : references.getReferencesTo(address)) {
+ Function caller = currentProgram.getFunctionManager().getFunctionContaining(reference.getFromAddress());
+ println("reference=" + reference.getFromAddress() + " caller=" + (caller == null ? "missing" : caller.getEntryPoint()));
+ }
+ }
+ }
+}