feat(script): decode compiled package framing
This commit is contained in:
Generated
+8
@@ -404,6 +404,7 @@ dependencies = [
|
|||||||
"fparkan-prototype",
|
"fparkan-prototype",
|
||||||
"fparkan-resource",
|
"fparkan-resource",
|
||||||
"fparkan-runtime",
|
"fparkan-runtime",
|
||||||
|
"fparkan-script",
|
||||||
"fparkan-vfs",
|
"fparkan-vfs",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
@@ -612,6 +613,13 @@ dependencies = [
|
|||||||
"fparkan-world",
|
"fparkan-world",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fparkan-script"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"fparkan-binary",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fparkan-terrain"
|
name = "fparkan-terrain"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ members = [
|
|||||||
"crates/fparkan-render",
|
"crates/fparkan-render",
|
||||||
"crates/fparkan-resource",
|
"crates/fparkan-resource",
|
||||||
"crates/fparkan-rsli",
|
"crates/fparkan-rsli",
|
||||||
|
"crates/fparkan-script",
|
||||||
"crates/fparkan-runtime",
|
"crates/fparkan-runtime",
|
||||||
"crates/fparkan-terrain",
|
"crates/fparkan-terrain",
|
||||||
"crates/fparkan-terrain-format",
|
"crates/fparkan-terrain-format",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ fparkan-inspection = { path = "../../crates/fparkan-inspection", version = "0.1.
|
|||||||
fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
|
fparkan-path = { path = "../../crates/fparkan-path", version = "0.1.0" }
|
||||||
fparkan-resource = { path = "../../crates/fparkan-resource", 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-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" }
|
fparkan-vfs = { path = "../../crates/fparkan-vfs", version = "0.1.0" }
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1";
|
|||||||
const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1";
|
const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1";
|
||||||
const MODEL_INSPECT_SCHEMA: &str = "fparkan-model-inspect-v1";
|
const MODEL_INSPECT_SCHEMA: &str = "fparkan-model-inspect-v1";
|
||||||
const WEAR_INSPECT_SCHEMA: &str = "fparkan-wear-inspect-v1";
|
const WEAR_INSPECT_SCHEMA: &str = "fparkan-wear-inspect-v1";
|
||||||
|
const SCRIPT_INSPECT_SCHEMA: &str = "fparkan-script-inspect-v1";
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct ArchiveInspectOutput<'a> {
|
struct ArchiveInspectOutput<'a> {
|
||||||
@@ -226,6 +227,17 @@ struct PrototypeGraphEdgeInspectOutput {
|
|||||||
resource_raw_hex: Option<String>,
|
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() {
|
fn main() {
|
||||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||||
let result = run(&args);
|
let result = run(&args);
|
||||||
@@ -290,10 +302,50 @@ fn run(args: &[String]) -> Result<(), String> {
|
|||||||
let rest = strip_format_json(rest)?;
|
let rest = strip_format_json(rest)?;
|
||||||
inspect_model(&rest)
|
inspect_model(&rest)
|
||||||
}
|
}
|
||||||
|
[domain, command, rest @ ..] if domain == "script" && command == "inspect" => {
|
||||||
|
let rest = strip_format_json(rest)?;
|
||||||
|
inspect_script(&rest)
|
||||||
|
}
|
||||||
_ => Err(usage()),
|
_ => 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 {
|
fn exit_code(result: &Result<(), String>) -> i32 {
|
||||||
if result.is_ok() {
|
if result.is_ok() {
|
||||||
0
|
0
|
||||||
@@ -769,7 +821,7 @@ fn prototype_graph_requiredness_label(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn usage() -> String {
|
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)]
|
#[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]
|
#[test]
|
||||||
fn archive_json_has_schema_version() {
|
fn archive_json_has_schema_version() {
|
||||||
let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true))
|
let json = archive_inspect_json("archive.lib", "NRes", 3, Some(true))
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,9 +10,16 @@ messages, teleports, задачи, research и campaign transitions. Точки
|
|||||||
и `briefing.cfg`.
|
и `briefing.cfg`.
|
||||||
|
|
||||||
`.scr` — binary package с version checks, symbol/event sections и offsets;
|
`.scr` — binary package с version checks, symbol/event sections и offsets;
|
||||||
полная opcode grammar не доказана. `.fml` — текстовый symbol/formula oracle;
|
полная opcode grammar не доказана. Его внешний framing теперь читает
|
||||||
`varset.var` задаёт `VAR(...)`/`STRING(...)` defaults; `.trf` — NRes tables,
|
`fparkan-script`: первый little-endian `u32` является числом required opcode
|
||||||
чей framing подтверждён, а field semantics местами лишь consumer-inferred.
|
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 фиксируются
|
byte: это ломает синхронизацию. Для каждого доказанного opcode фиксируются
|
||||||
number, size, operands, control flow, effects, errors и минимальный test.
|
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 не
|
TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не
|
||||||
задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе
|
задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе
|
||||||
сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state,
|
сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state,
|
||||||
|
|||||||
@@ -133,6 +133,16 @@ Wizard получает желаемое направление и corridor, а
|
|||||||
описывает типы, defaults, ranges и строки через макросоподобные формы
|
описывает типы, defaults, ranges и строки через макросоподобные формы
|
||||||
`VAR(...)` и `STRING(...)`.
|
`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-модель:
|
Безопасная runtime-модель:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ schema = 1
|
|||||||
"4" = [
|
"4" = [
|
||||||
"fparkan-animation",
|
"fparkan-animation",
|
||||||
"fparkan-fx",
|
"fparkan-fx",
|
||||||
|
"fparkan-script",
|
||||||
]
|
]
|
||||||
"5" = [
|
"5" = [
|
||||||
"fparkan-game",
|
"fparkan-game",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user