diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs index 6ee666f..0c2eaa0 100644 --- a/crates/fparkan-script/src/lib.rs +++ b/crates/fparkan-script/src/lib.rs @@ -50,6 +50,116 @@ pub enum VarSetDefault { Dword(u32), } +/// One opaque command delivered by a VM handler to the host-supplied callback. +/// +/// The numeric mode belongs to the callback ABI; it has no inferred gameplay +/// meaning yet. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct VmHostCallbackCommand { + /// First callback ABI word. + pub mode: u32, + /// First resolved callback payload word. + pub first: u32, + /// Second resolved callback payload word. + pub second: u32, +} + +/// Error resolving the proven `Handler(30)` callback contract. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Handler30ResolveError { + /// The instruction did not contain one of Handler(30)'s required references. + MissingReference { + /// Zero-based required reference position. + position: usize, + }, + /// A reference index was outside the loaded varset. + VarSetIndexOutOfBounds { + /// Referenced index from the compiled instruction. + index: u32, + /// Available declaration count. + declarations: usize, + }, + /// A `float` declaration would require the still-unrecovered x87 `__ftol` policy. + FloatRequiresX87 { + /// Referenced index from the compiled instruction. + index: u32, + }, +} + +impl std::fmt::Display for Handler30ResolveError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingReference { position } => { + write!(formatter, "Handler(30) is missing reference {position}") + } + Self::VarSetIndexOutOfBounds { + index, + declarations, + } => write!( + formatter, + "Handler(30) reference {index} is outside {declarations} varset declarations" + ), + Self::FloatRequiresX87 { index } => write!( + formatter, + "Handler(30) reference {index} requires unrecovered x87 float-to-u32 conversion" + ), + } + } +} + +impl std::error::Error for Handler30ResolveError {} + +impl VarSet { + /// Resolves the two index operands consumed by the GOG `Handler(30)`. + /// + /// The original invokes its host callback with mode zero after resolving + /// both operands through `FUN_10013570`. The shipped GOG corpus uses only + /// `DWORD` declarations at these positions. A float is rejected until a + /// captured x87 `__ftol` conversion profile exists. + /// + /// # Errors + /// + /// Returns a typed error for missing references, out-of-range varset + /// indices, or a float operand requiring unrecovered x87 behavior. + pub fn resolve_handler30( + &self, + instruction: &ScriptInstruction, + ) -> Result { + let first = self.resolve_handler30_operand(instruction, 0)?; + let second = self.resolve_handler30_operand(instruction, 1)?; + Ok(VmHostCallbackCommand { + mode: 0, + first, + second, + }) + } + + fn resolve_handler30_operand( + &self, + instruction: &ScriptInstruction, + 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 { + index, + declarations: self.declarations.len(), + }), + } + } +} + impl VarSetDefault { /// Returns the float value when this is a `float` default. #[must_use] @@ -583,8 +693,8 @@ fn read_instruction( mod tests { use super::{ decode, decode_with_limits, parse_varset, Handler2RecordInput, Handler2RecordScheduler, - ScriptDispatchSelector, VarSetDefault, VarSetError, VarSetType, GOG_HANDLER_COUNT, - INSTRUCTION_WORDS, + Handler30ResolveError, ScriptDispatchSelector, ScriptInstruction, VarSetDefault, + VarSetError, VarSetType, VmHostCallbackCommand, GOG_HANDLER_COUNT, INSTRUCTION_WORDS, }; use fparkan_binary::{DecodeError, Limits}; @@ -718,6 +828,59 @@ STRING( 8, ignored, ignored, ignored)\r\n"; ); } + #[test] + fn handler_thirty_resolves_only_proven_dword_operands() { + let varset = parse_varset( + b"VAR( float, f0, 0.5)\nVAR( DWORD, first, 0x12)\nVAR( DWORD, second, 9)\n", + ) + .expect("varset"); + let instruction = ScriptInstruction { + header_words: [30, 0, 0, 0, 0, 2, 0], + references: vec![1, 2], + }; + assert_eq!( + varset.resolve_handler30(&instruction), + Ok(VmHostCallbackCommand { + mode: 0, + first: 0x12, + second: 9, + }) + ); + } + + #[test] + fn handler_thirty_keeps_float_and_malformed_references_explicit() { + let varset = parse_varset(b"VAR( float, f0, 0.5)\n").expect("varset"); + let float_operand = ScriptInstruction { + header_words: [30, 0, 0, 0, 0, 2, 0], + references: vec![0, 0], + }; + assert_eq!( + varset.resolve_handler30(&float_operand), + Err(Handler30ResolveError::FloatRequiresX87 { index: 0 }) + ); + let dword_varset = parse_varset(b"VAR( DWORD, d0, 1)\n").expect("dword varset"); + let missing_operand = ScriptInstruction { + header_words: [30, 0, 0, 0, 0, 1, 0], + references: vec![0], + }; + assert_eq!( + dword_varset.resolve_handler30(&missing_operand), + Err(Handler30ResolveError::MissingReference { position: 1 }) + ); + let out_of_range = ScriptInstruction { + header_words: [30, 0, 0, 0, 0, 2, 0], + references: vec![1, 1], + }; + assert_eq!( + dword_varset.resolve_handler30(&out_of_range), + Err(Handler30ResolveError::VarSetIndexOutOfBounds { + index: 1, + declarations: 1, + }) + ); + } + #[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 e222ab8..e545a3b 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -128,11 +128,22 @@ non-sentinel selector — `Handler(30)`, 246 records. Его VA `0x1000c266` у `Handler(57)` с первым word `2` и у отдельного lifecycle path с первым word `1`; предметная семантика этих modes ещё не доказана. В частности, это пока не основание назвать Handler(30) сообщением, приказом или UI opcode. Точный -text-to-varset resolver ещё расположен за wrapper `0x10011ea0` в -`0x100174a0`, поэтому Rust не dispatch-ит Handler(30) до восстановления -индексации. Воспроизводимые exports: `ExportAiVmHandler30.java`, +text-to-varset resolver расположен за wrapper `0x10011ea0` в +`0x100174a0`. Воспроизводимые exports: `ExportAiVmHandler30.java`, `FindAiVmHandler30Callback.java`, `ExportAiVarSetLoader.java`. +Следующий pass восстанавливает эту индексацию. `0x100174a0` добавляет каждый +recognized source declaration в encounter order как 48-byte record; GOG shared +`varset.var` не содержит `STRING(...)`, поэтому его 231 numeric `VAR` entries +образуют точно это index space. `0x10013570` возвращает `DWORD` record kind +raw `u32`; float kind проходит `__ftol`, чей x87 rounding profile ещё требует +capture. Полный GOG scan всех 246 Handler(30) instructions показывает 492 +operand references: все 492 in-range и указывают на `DWORD`. Поэтому +`VarSet::resolve_handler30` уже materializes точный opaque callback command +`(mode=0, first, second)` для данного corpus path, но явно отклоняет float, +out-of-range и incomplete instructions вместо silent coercion. Extractors: +`ExportAiVarSetParser.java`, `ExportAiVarSetU32Resolver.java`. + Следующий static pass закрывает equality/update policy. Identity ровно равна `(slot0 word, slot4 IEEE-754 bits, slot5 IEEE-754 bits)`, поэтому `-0.0` и `+0.0` различаются. Новый 100-byte record получает slot1 в поле `+0x14`, diff --git a/tools/ghidra/ExportAiVarSetParser.java b/tools/ghidra/ExportAiVarSetParser.java new file mode 100644 index 0000000..f425f30 --- /dev/null +++ b/tools/ghidra/ExportAiVarSetParser.java @@ -0,0 +1,23 @@ +// Emits the actual text-to-varset parser called by the AI varset loader. +// 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 ExportAiVarSetParser extends GhidraScript { + private static final long ADDRESS = 0x100174a0L; + + @Override + public void run() throws Exception { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress(ADDRESS); + Function function = currentProgram.getFunctionManager().getFunctionAt(address); + println("===== AI varset parser ====="); + 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/ExportAiVarSetU32Resolver.java b/tools/ghidra/ExportAiVarSetU32Resolver.java new file mode 100644 index 0000000..247220d --- /dev/null +++ b/tools/ghidra/ExportAiVarSetU32Resolver.java @@ -0,0 +1,23 @@ +// Emits the u32 resolver used by corpus-reachable Handler(30) for indexed +// varset records. 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 ExportAiVarSetU32Resolver extends GhidraScript { + private static final long ADDRESS = 0x10013570L; + + @Override + public void run() throws Exception { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress(ADDRESS); + Function function = currentProgram.getFunctionManager().getFunctionAt(address); + println("===== AI varset u32 resolver ====="); + if (function == null) { println("missing"); return; } + DecompInterface decompiler = new DecompInterface(); + decompiler.openProgram(currentProgram); + println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC()); + decompiler.dispose(); + } +}