diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs index 0c2eaa0..ab2e814 100644 --- a/crates/fparkan-script/src/lib.rs +++ b/crates/fparkan-script/src/lib.rs @@ -64,6 +64,119 @@ pub struct VmHostCallbackCommand { pub second: u32, } +/// The optional target-lookup payload selected by the recovered `Handler(15)` +/// mode word. Names follow the shipped `varset.var` constants; they do not +/// assign a gameplay operation to the later opaque virtual call. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Handler15TargetPayload { + /// `NONE` (`0`): no additional reference is consumed. + None, + /// `TARGET_BY_LOGIC_ID` (`0x0201`): one DWORD payload. + ByLogicId(u32), + /// `TARGET_BY_PLACE` (`0x0202`): two DWORD payloads. + ByPlace { + /// First DWORD payload in source order. + first: u32, + /// Second DWORD payload in source order. + second: u32, + }, + /// `TARGET_BY_TYPE` (`0x0203`): one DWORD payload. + ByType(u32), + /// `TARGET_NOT_DEFINED` (`0x0204`): one DWORD payload. + NotDefined(u32), + /// `TARGET_BY_NAME` (`0x0205`): one DWORD payload. + ByName(u32), +} + +/// Resolved fixed-width operands delivered by `Handler(15)` to its target +/// interface. +/// +/// Slot names preserve disk/reference order. The original first looks up an +/// opaque target through `word_0`, then calls a target vtable slot with this +/// packed data and `word_2`; neither operation is named here. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Handler15Invocation { + /// Resolved DWORD reference 0, used for the preceding target lookup. + pub word_0: u32, + /// Resolved DWORD reference 1. + pub word_1: u32, + /// Resolved DWORD reference 2, passed separately to the target call. + pub word_2: u32, + /// Resolved DWORD reference 3. + pub word_3: u32, + /// Resolved scalar reference 4. + pub scalar_4: f32, + /// Resolved scalar reference 5. + pub scalar_5: f32, + /// Resolved scalar reference 6. + pub scalar_6: f32, + /// Resolved scalar reference 7. + pub scalar_7: f32, + /// Target mode and its exactly consumed trailing DWORD references. + pub target: Handler15TargetPayload, +} + +/// Error resolving the corpus-proven `Handler(15)` input layout. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Handler15ResolveError { + /// One of the mode-specific required references was absent. + MissingReference { + /// Zero-based required reference position. + position: usize, + }, + /// A compiled reference indexed outside the loaded varset. + VarSetIndexOutOfBounds { + /// Referenced compiled-varset index. + index: u32, + /// Available declaration count. + declarations: usize, + }, + /// A reference's declaration type differed from the observed contract. + UnexpectedType { + /// Zero-based reference position. + position: usize, + /// Type required by the recovered handler contract. + expected: VarSetType, + /// Type actually declared in the loaded varset. + found: VarSetType, + }, + /// The target-mode value was not among the modes observed in the GOG corpus. + UnsupportedTargetMode(u32), +} + +impl std::fmt::Display for Handler15ResolveError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingReference { position } => { + write!(formatter, "Handler(15) is missing reference {position}") + } + Self::VarSetIndexOutOfBounds { + index, + declarations, + } => write!( + formatter, + "Handler(15) reference {index} is outside {declarations} varset declarations" + ), + Self::UnexpectedType { + position, + expected, + found, + } => write!( + formatter, + "Handler(15) reference {position} has {found:?}, expected {expected:?}" + ), + Self::UnsupportedTargetMode(mode) => { + write!( + formatter, + "Handler(15) has unsupported target mode 0x{mode:08x}" + ) + } + } + } +} + +impl std::error::Error for Handler15ResolveError {} + /// Error resolving the proven `Handler(30)` callback contract. #[derive(Clone, Debug, Eq, PartialEq)] pub enum Handler30ResolveError { @@ -158,6 +271,114 @@ impl VarSet { }), } } + + /// Resolves the fixed-width, corpus-proven operands of GOG `Handler(15)`. + /// + /// Static analysis of `ai.dll` at `0x10008054` shows four DWORD slots, + /// four scalar slots, then a DWORD mode. The mode selects zero, one, or + /// two additional DWORD references. This method deliberately stops before + /// the original target lookup and virtual call because their object and + /// gameplay semantics are not yet recovered. + /// + /// # Errors + /// + /// Returns typed failures for missing/out-of-range operands, type changes, + /// or an unobserved target mode rather than silently inventing a command. + pub fn resolve_handler15( + &self, + instruction: &ScriptInstruction, + ) -> Result { + let word_0 = self.resolve_handler15_dword(instruction, 0)?; + let word_1 = self.resolve_handler15_dword(instruction, 1)?; + let word_2 = self.resolve_handler15_dword(instruction, 2)?; + let word_3 = self.resolve_handler15_dword(instruction, 3)?; + let scalar_4 = self.resolve_handler15_float(instruction, 4)?; + let scalar_5 = self.resolve_handler15_float(instruction, 5)?; + let scalar_6 = self.resolve_handler15_float(instruction, 6)?; + let scalar_7 = self.resolve_handler15_float(instruction, 7)?; + let target = match self.resolve_handler15_dword(instruction, 8)? { + 0 => Handler15TargetPayload::None, + 0x0201 => { + Handler15TargetPayload::ByLogicId(self.resolve_handler15_dword(instruction, 9)?) + } + 0x0202 => Handler15TargetPayload::ByPlace { + first: self.resolve_handler15_dword(instruction, 9)?, + second: self.resolve_handler15_dword(instruction, 10)?, + }, + 0x0203 => Handler15TargetPayload::ByType(self.resolve_handler15_dword(instruction, 9)?), + 0x0204 => { + Handler15TargetPayload::NotDefined(self.resolve_handler15_dword(instruction, 9)?) + } + 0x0205 => Handler15TargetPayload::ByName(self.resolve_handler15_dword(instruction, 9)?), + mode => return Err(Handler15ResolveError::UnsupportedTargetMode(mode)), + }; + Ok(Handler15Invocation { + word_0, + word_1, + word_2, + word_3, + scalar_4, + scalar_5, + scalar_6, + scalar_7, + target, + }) + } + + fn resolve_handler15_dword( + &self, + instruction: &ScriptInstruction, + position: usize, + ) -> Result { + match self.resolve_handler15_declaration(instruction, position)? { + VarSetDeclaration { + type_name: VarSetType::Dword, + default_value: VarSetDefault::Dword(value), + .. + } => Ok(*value), + declaration => Err(Handler15ResolveError::UnexpectedType { + position, + expected: VarSetType::Dword, + found: declaration.type_name, + }), + } + } + + fn resolve_handler15_float( + &self, + instruction: &ScriptInstruction, + position: usize, + ) -> Result { + match self.resolve_handler15_declaration(instruction, position)? { + VarSetDeclaration { + type_name: VarSetType::Float, + default_value: VarSetDefault::FloatBits(bits), + .. + } => Ok(f32::from_bits(*bits)), + declaration => Err(Handler15ResolveError::UnexpectedType { + position, + expected: VarSetType::Float, + found: declaration.type_name, + }), + } + } + + fn resolve_handler15_declaration( + &self, + instruction: &ScriptInstruction, + position: usize, + ) -> Result<&VarSetDeclaration, Handler15ResolveError> { + let index = *instruction + .references + .get(position) + .ok_or(Handler15ResolveError::MissingReference { position })?; + self.declarations + .get(index as usize) + .ok_or(Handler15ResolveError::VarSetIndexOutOfBounds { + index, + declarations: self.declarations.len(), + }) + } } impl VarSetDefault { @@ -692,9 +913,10 @@ fn read_instruction( #[cfg(test)] mod tests { use super::{ - decode, decode_with_limits, parse_varset, Handler2RecordInput, Handler2RecordScheduler, - Handler30ResolveError, ScriptDispatchSelector, ScriptInstruction, VarSetDefault, - VarSetError, VarSetType, VmHostCallbackCommand, GOG_HANDLER_COUNT, INSTRUCTION_WORDS, + decode, decode_with_limits, parse_varset, Handler15ResolveError, Handler15TargetPayload, + Handler2RecordInput, Handler2RecordScheduler, Handler30ResolveError, + ScriptDispatchSelector, ScriptInstruction, VarSetDefault, VarSetError, VarSetType, + VmHostCallbackCommand, GOG_HANDLER_COUNT, INSTRUCTION_WORDS, }; use fparkan_binary::{DecodeError, Limits}; @@ -881,6 +1103,80 @@ STRING( 8, ignored, ignored, ignored)\r\n"; ); } + #[test] + fn handler_fifteen_resolves_exact_typed_target_by_place_operands() { + let varset = parse_varset( + b"VAR( DWORD, d0, 1)\nVAR( DWORD, d1, 2)\nVAR( DWORD, d2, 3)\nVAR( DWORD, d3, 4)\nVAR( float, f4, 1.5)\nVAR( float, f5, 2.5)\nVAR( float, f6, 3.5)\nVAR( float, f7, 4.5)\nVAR( DWORD, mode, 0x0202)\nVAR( DWORD, place0, 12)\nVAR( DWORD, place1, 13)\n", + ) + .expect("valid Handler(15) varset"); + let instruction = ScriptInstruction { + header_words: [15, 0, 0, 0, 0, 11, 0], + references: (0..11).collect(), + }; + + let resolved = varset + .resolve_handler15(&instruction) + .expect("exact Handler(15) layout"); + assert_eq!( + [ + resolved.word_0, + resolved.word_1, + resolved.word_2, + resolved.word_3 + ], + [1, 2, 3, 4] + ); + assert_eq!( + [ + resolved.scalar_4.to_bits(), + resolved.scalar_5.to_bits(), + resolved.scalar_6.to_bits(), + resolved.scalar_7.to_bits() + ], + [ + 1.5_f32.to_bits(), + 2.5_f32.to_bits(), + 3.5_f32.to_bits(), + 4.5_f32.to_bits() + ] + ); + assert_eq!( + resolved.target, + Handler15TargetPayload::ByPlace { + first: 12, + second: 13 + } + ); + } + + #[test] + fn handler_fifteen_rejects_missing_and_wrongly_typed_operands() { + let wrong_scalar = parse_varset( + b"VAR( DWORD, d0, 1)\nVAR( DWORD, d1, 2)\nVAR( DWORD, d2, 3)\nVAR( DWORD, d3, 4)\nVAR( DWORD, wrong, 5)\n", + ) + .expect("valid varset"); + let instruction = ScriptInstruction { + header_words: [15, 0, 0, 0, 0, 5, 0], + references: (0..5).collect(), + }; + assert_eq!( + wrong_scalar.resolve_handler15(&instruction), + Err(Handler15ResolveError::UnexpectedType { + position: 4, + expected: VarSetType::Float, + found: VarSetType::Dword, + }) + ); + let empty = parse_varset(b"").expect("empty varset"); + assert_eq!( + empty.resolve_handler15(&ScriptInstruction { + header_words: [15, 0, 0, 0, 0, 0, 0], + references: Vec::new(), + }), + Err(Handler15ResolveError::MissingReference { position: 0 }) + ); + } + #[test] fn decodes_lossless_event_and_instruction_records() { let mut bytes = Vec::new(); diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index e545a3b..11ff983 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -173,6 +173,35 @@ layout prerequisites/modifiers/unlocks. Formula evaluator требует strict grammar/version, typed operands, deterministic numeric policy, bounded stack и явных errors; x87-compatible rounding нужен там, где оно выбирает ветку. +### Handler(15): typed target-call boundary + +`Handler(15)` is the sixteenth VM-table entry at GOG `ai.dll` VA `0x10008054` +and the second most frequent non-sentinel selector: 236 records in 28 of 58 +GOG packages. It is not a one-word opcode. Across the complete corpus, +references `0..3` are `DWORD`, `4..7` are `float`, and `8` is the DWORD mode. +The original resolves the first four through `0x10013570`, the next four +through x87 scalar helper `0x10013190`, then reads the mode through +`0x10013570`. + +The shipped `varset.var` names the only observed mode values: `NONE=0` uses +9 total references; `TARGET_BY_LOGIC_ID=0x0201`, `TARGET_BY_TYPE=0x0203`, +`TARGET_NOT_DEFINED=0x0204`, and `TARGET_BY_NAME=0x0205` use 10; and +`TARGET_BY_PLACE=0x0202` uses 11. The corpus contains 34/122/80 records in +these three arities. The trailing references at positions 9 and, only for +`TARGET_BY_PLACE`, 10 are all in-range DWORD declarations. + +After resolving those inputs, the handler looks up an opaque target through +virtual slot `+0x1c` on `this+0x3d8` using the first word. A missing target +sets VM flag `+0x50=5`; otherwise the handler builds a temporary call record, +applies the mode-specific tail, and invokes the target's `+0x0c` virtual slot +with that record and the third word. Its zero/non-zero result becomes +`+0x50=0/1`. The target type, the virtual method's semantic action, and its +return value remain unproven. Accordingly `VarSet::resolve_handler15` only +materializes a type-checked `Handler15Invocation` and `Handler15TargetPayload`; +it never executes the opaque target call. Missing, out-of-range, wrong-type, +and unobserved-mode inputs are explicit errors. Reproduce the static evidence +with `tools/ghidra/ExportAiVmHandler15.java`. + ## Готовность Все demo packages должны проходить package/version checks, offsets оставаться diff --git a/tools/ghidra/ExportAiVmHandler15.java b/tools/ghidra/ExportAiVmHandler15.java new file mode 100644 index 0000000..d1756aa --- /dev/null +++ b/tools/ghidra/ExportAiVmHandler15.java @@ -0,0 +1,23 @@ +// Emits Handler(15), a frequent non-sentinel selector in the GOG compiled +// script corpus. Run headless; the original PE remains 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 ExportAiVmHandler15 extends GhidraScript { + private static final long ADDRESS = 0x10008054L; + + @Override + public void run() throws Exception { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress(ADDRESS); + Function function = currentProgram.getFunctionManager().getFunctionAt(address); + println("===== AI VM Handler(15) ====="); + if (function == null) { println("missing"); return; } + DecompInterface decompiler = new DecompInterface(); + decompiler.openProgram(currentProgram); + println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC()); + decompiler.dispose(); + } +}