diff options
| author | Valentin Popov <valentin@popov.link> | 2026-07-18 19:40:17 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-07-18 19:40:17 +0300 |
| commit | d31d1f8eab7a2c79e349748939de01895af5de7e (patch) | |
| tree | 89546aab24629deebaf88c3694e49ef2a547a3e2 /crates | |
| parent | a7b434aeb2e1c79131e4919f6b0cbf66cdc06599 (diff) | |
| download | fparkan-d31d1f8eab7a2c79e349748939de01895af5de7e.tar.xz fparkan-d31d1f8eab7a2c79e349748939de01895af5de7e.zip | |
feat(script): decode compiled package framing
Diffstat (limited to 'crates')
| -rw-r--r-- | crates/fparkan-script/Cargo.toml | 12 | ||||
| -rw-r--r-- | crates/fparkan-script/src/lib.rs | 254 |
2 files changed, 266 insertions, 0 deletions
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 { .. }) + )); + } +} |
