From bbefe65a6287fad5424e3a5d2b91664b555e90dc Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 22:25:28 +0400 Subject: [PATCH] feat(script): bind callbacks to runtime varsets --- crates/fparkan-runtime/src/lib.rs | 88 ++++++++++++++++++++++++++++++- crates/fparkan-script/src/lib.rs | 79 ++++++++++++++++++++++----- docs/appendices/script-vm.md | 9 ++++ 3 files changed, 160 insertions(+), 16 deletions(-) diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs index 771a1d4..cc58185 100644 --- a/crates/fparkan-runtime/src/lib.rs +++ b/crates/fparkan-runtime/src/lib.rs @@ -35,8 +35,8 @@ use fparkan_prototype::{ }; use fparkan_resource::{resource_name, CachedResourceRepository, ResourceRepository}; use fparkan_script::{ - Handler19DwordWrite, Handler19InitInput, ScriptDispatchSelector, ScriptPackage, VarSet, - VarSetDefault, + Handler19DwordWrite, Handler19InitInput, ScriptDispatchSelector, ScriptInstruction, + ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand, }; use fparkan_terrain::SurfaceQuery; use fparkan_vfs::{Vfs, VfsError}; @@ -487,6 +487,13 @@ pub enum EngineError { /// Proven-contract diagnostic. message: String, }, + /// A recovered script handler could not resolve against runtime state. + ScriptRuntime { + /// Owning TMA clan index. + clan_index: usize, + /// Handler-specific diagnostic. + message: String, + }, /// `NRes` decode error. Nres { /// Resource path. @@ -627,6 +634,10 @@ impl std::fmt::Display for EngineError { clan_index, message, } => write!(f, "clan {clan_index}: script Init failed: {message}"), + Self::ScriptRuntime { + clan_index, + message, + } => write!(f, "clan {clan_index}: script runtime failed: {message}"), Self::Nres { path, source } => write!(f, "{path}: {source}"), Self::Mission { path, source } => write!(f, "{path}: {source}"), Self::TerrainFormat { path, source } => write!(f, "{path}: {source}"), @@ -670,6 +681,7 @@ impl std::error::Error for EngineError { | Self::Script { .. } | Self::VarSet { .. } | Self::ScriptInit { .. } + | Self::ScriptRuntime { .. } | Self::Movement(_) | Self::PrototypeGraph { .. } | Self::SchedulerPhaseOrder { .. } @@ -1253,6 +1265,61 @@ pub fn loaded_mission_script_varset_states(engine: &Engine) -> Option<&[MissionS .map(|state| state.script_varset_states.as_slice()) } +/// Resolves the proven opaque callback command for one `Handler(30)` record. +/// +/// Unlike the loader-only resolver, operands are read from the selected +/// clan's instantiated varset cells. Dispatching the returned host callback +/// remains outside this function because its game-side consumer is not yet +/// recovered. +/// +/// # Errors +/// +/// Returns an explicit error when no mission, declaration table, or clan state +/// is available, or if the instruction violates the proven handler contract. +pub fn resolve_loaded_handler30( + engine: &Engine, + clan_index: usize, + instruction: &ScriptInstruction, +) -> Result { + let state = engine + .loaded + .as_ref() + .ok_or_else(|| EngineError::ScriptRuntime { + clan_index, + message: "mission script state is unavailable".to_string(), + })?; + let declarations = state + .script_varset + .as_ref() + .ok_or_else(|| EngineError::ScriptRuntime { + clan_index, + message: "mission varset declarations are unavailable".to_string(), + })?; + let values = state + .script_varset_states + .iter() + .find(|varset| varset.clan_index == clan_index) + .ok_or_else(|| EngineError::ScriptRuntime { + clan_index, + message: "instantiated mission varset is unavailable".to_string(), + })?; + resolve_handler30_from_varset_state(declarations, values, instruction) +} + +fn resolve_handler30_from_varset_state( + declarations: &MissionScriptVarSet, + values: &MissionScriptVarSetState, + instruction: &ScriptInstruction, +) -> Result { + declarations + .declarations + .resolve_handler30_with_values(instruction, &values.values) + .map_err(|source| EngineError::ScriptRuntime { + clan_index: values.clan_index, + message: source.to_string(), + }) +} + /// Returns terrain runtime data for the loaded mission. #[must_use] pub fn loaded_terrain(engine: &Engine) -> Option<&TerrainWorld> { @@ -1766,6 +1833,23 @@ mod tests { ], }] ); + let callback = resolve_handler30_from_varset_state( + &varset, + &runtime_varsets[0], + &ScriptInstruction { + header_words: [30, 0, 0, 0, 0, 2, 0], + references: vec![0, 2], + }, + ) + .expect("runtime Handler(30) callback"); + assert_eq!( + callback, + VmHostCallbackCommand { + mode: 0, + first: 728, + second: 1, + } + ); } #[test] diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs index 9acbcb7..6887a03 100644 --- a/crates/fparkan-script/src/lib.rs +++ b/crates/fparkan-script/src/lib.rs @@ -378,8 +378,32 @@ impl VarSet { &self, instruction: &ScriptInstruction, ) -> Result { - let first = self.resolve_handler30_operand(instruction, 0)?; - let second = self.resolve_handler30_operand(instruction, 1)?; + let values: Vec<_> = self + .declarations + .iter() + .map(|declaration| declaration.default_value) + .collect(); + self.resolve_handler30_with_values(instruction, &values) + } + + /// Resolves `Handler(30)` against instantiated runtime varset values. + /// + /// The declaration table still establishes the original numeric kind; the + /// supplied cells carry state after prior recovered handlers have written + /// it. This is the execution-time counterpart of [`Self::resolve_handler30`]. + /// + /// # Errors + /// + /// Returns the same bounded errors as [`Self::resolve_handler30`] when an + /// instruction, declaration, or runtime cell cannot satisfy the proven + /// DWORD operand contract. + pub fn resolve_handler30_with_values( + &self, + instruction: &ScriptInstruction, + values: &[VarSetDefault], + ) -> Result { + let first = self.resolve_handler30_operand_with_values(instruction, values, 0)?; + let second = self.resolve_handler30_operand_with_values(instruction, values, 1)?; Ok(VmHostCallbackCommand { mode: 0, first, @@ -387,28 +411,34 @@ impl VarSet { }) } - fn resolve_handler30_operand( + fn resolve_handler30_operand_with_values( &self, instruction: &ScriptInstruction, + values: &[VarSetDefault], position: usize, ) -> Result { let index = *instruction .references .get(position) .ok_or(Handler30ResolveError::MissingReference { position })?; - match self.declarations.get(index as usize) { - Some(VarSetDeclaration { - default_value: VarSetDefault::Dword(value), - .. - }) => Ok(*value), - Some(VarSetDeclaration { - default_value: VarSetDefault::FloatBits(_), - .. - }) => Err(Handler30ResolveError::FloatRequiresX87 { index }), - None => Err(Handler30ResolveError::VarSetIndexOutOfBounds { + let declaration = self.declarations.get(index as usize).ok_or( + Handler30ResolveError::VarSetIndexOutOfBounds { index, declarations: self.declarations.len(), - }), + }, + )?; + let value = + values + .get(index as usize) + .ok_or(Handler30ResolveError::VarSetIndexOutOfBounds { + index, + declarations: values.len(), + })?; + match (declaration.type_name, value) { + (VarSetType::Dword, VarSetDefault::Dword(value)) => Ok(*value), + (VarSetType::Float, _) | (_, VarSetDefault::FloatBits(_)) => { + Err(Handler30ResolveError::FloatRequiresX87 { index }) + } } } @@ -1323,6 +1353,27 @@ STRING( 8, ignored, ignored, ignored)\r\n"; ); } + #[test] + fn handler_thirty_reads_instantiated_dword_cells_instead_of_defaults() { + let varset = + parse_varset(b"VAR( DWORD, first, 0x12)\nVAR( DWORD, second, 9)\n").expect("varset"); + let instruction = ScriptInstruction { + header_words: [30, 0, 0, 0, 0, 2, 0], + references: vec![0, 1], + }; + assert_eq!( + varset.resolve_handler30_with_values( + &instruction, + &[VarSetDefault::Dword(728), VarSetDefault::Dword(449)], + ), + Ok(VmHostCallbackCommand { + mode: 0, + first: 728, + second: 449, + }) + ); + } + #[test] fn handler_thirty_keeps_float_and_malformed_references_explicit() { let varset = parse_varset(b"VAR( float, f0, 0.5)\n").expect("varset"); diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 59c1512..7141582 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -231,6 +231,15 @@ The creation and conversion boundaries are reproducible with `ExportAiCreateSuperAi.java`, `ExportAiSuperAiConstructor.java`, and `ExportAiFtol.java`. +### Runtime Handler(30) operand binding + +`resolve_handler30_with_values` preserves the recovered declaration-kind ABI +but reads operands from instantiated per-clan cells rather than textual +defaults. Runtime exposes `resolve_loaded_handler30` for the exact opaque +`(mode=0, first, second)` callback command. It intentionally returns that +command without invoking a guessed game-side consumer: the tenth +`CreateSuperAI` callback argument still needs its own recovery. + ### Handler(8): problem-record state write `Handler(8)` is the ninth VM-table entry at GOG `ai.dll` VA `0x10009b0d`.