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", @@ -612,6 +613,13 @@ dependencies = [ "fparkan-world", ] +[[package]] +name = "fparkan-script" +version = "0.1.0" +dependencies = [ + "fparkan-binary", +] + [[package]] name = "fparkan-terrain" version = "0.1.0" 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, } +#[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 = 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 { + 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 [--format json] | archive inspect [--format json] | terrain inspect [--format json] | wear inspect --root --archive --resource [--format json] | model inspect --root --archive --resource [--format json] | prototype inspect --root --key [--format json] | mission graph|inspect --root --mission [--format json]".to_string() + "usage: fparkan corpus discover|validate --root [--format json] | archive inspect [--format json] | terrain inspect [--format json] | wear inspect --root --archive --resource [--format json] | model inspect --root --archive --resource [--format json] | script inspect [--format json] | prototype inspect --root --key [--format json] | mission graph|inspect --root --mission [--format json]".to_string() } #[cfg(test)] @@ -798,6 +850,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)) 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, + /// Bytes not consumed by the recovered framing. + pub trailing_bytes: Vec, + /// 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, + /// Opaque event word following the name. + pub event_word: u32, + /// Nested instruction records in original file order. + pub instructions: Vec, +} + +/// 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, +} + +/// 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 { + 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 { + 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 { + 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 { + 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` открывает `.scr`, `varset.var`, `.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())); + } + } + } +}