From 5d40ba3968d73bbe08808ff0b64daf6661b137f8 Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Sat, 18 Jul 2026 21:53:09 +0400 Subject: [PATCH] feat(script): resolve handler eight states --- crates/fparkan-script/src/lib.rs | 192 +++++++++++++++++- docs/appendices/script-vm.md | 20 ++ tools/ghidra/ExportAiVmHandler8.java | 23 +++ tools/ghidra/ExportAiVmHandler8Callees.java | 25 +++ .../ghidra/ExportAiVmHandler8Transitions.java | 25 +++ 5 files changed, 282 insertions(+), 3 deletions(-) create mode 100644 tools/ghidra/ExportAiVmHandler8.java create mode 100644 tools/ghidra/ExportAiVmHandler8Callees.java create mode 100644 tools/ghidra/ExportAiVmHandler8Transitions.java diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs index ab2e814..2f8b703 100644 --- a/crates/fparkan-script/src/lib.rs +++ b/crates/fparkan-script/src/lib.rs @@ -116,6 +116,75 @@ pub struct Handler15Invocation { pub target: Handler15TargetPayload, } +/// The proven reset branch selected before a `Handler(8)` state write. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum Handler8Reset { + /// No reset helper is called before the state field is written. + None, + /// State `2`: helper resets record words `0..=3` and `6`, then invokes + /// two still-opaque callback slots. + StateTwo, + /// State `3`: helper resets record words `0..=2` and `6`, then invokes + /// the same callback slots. + StateThree, +} + +/// One opaque 100-byte AI-record state transition requested by `Handler(8)`. +/// +/// The record index is the runtime value of original `dCurrentProblem`; it is +/// intentionally separate from a compiled instruction reference. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Handler8StateChange { + /// Checked `dCurrentProblem` record index in the handler's `this + 0xa0` table. + pub record_index: u32, + /// Resolved instruction DWORD written at record offset `+0x18`. + pub next_state: u32, + /// Proven pre-write reset branch. + pub reset: Handler8Reset, +} + +/// Error resolving the one-operand `Handler(8)` contract. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum Handler8ResolveError { + /// The instruction omitted its required DWORD reference. + MissingReference, + /// The instruction reference is outside the loaded varset. + VarSetIndexOutOfBounds { + /// Referenced compiled-varset index. + index: u32, + /// Available declaration count. + declarations: usize, + }, + /// The reference was not a DWORD declaration. + UnexpectedType { + /// Observed declaration type. + found: VarSetType, + }, +} + +impl std::fmt::Display for Handler8ResolveError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MissingReference => write!(formatter, "Handler(8) is missing reference 0"), + Self::VarSetIndexOutOfBounds { + index, + declarations, + } => write!( + formatter, + "Handler(8) reference {index} is outside {declarations} varset declarations" + ), + Self::UnexpectedType { found } => { + write!( + formatter, + "Handler(8) reference 0 has {found:?}, expected Dword" + ) + } + } + } +} + +impl std::error::Error for Handler8ResolveError {} + /// Error resolving the corpus-proven `Handler(15)` input layout. #[derive(Clone, Debug, Eq, PartialEq)] pub enum Handler15ResolveError { @@ -272,6 +341,56 @@ impl VarSet { } } + /// Resolves the one DWORD operand consumed by GOG `Handler(8)`. + /// + /// `current_problem_record_index` is the caller's live value of the + /// loader-bound `dCurrentProblem` varset entry. The original bounds-checks + /// it against a table at `this + 0xa0`; table ownership and callback + /// semantics remain outside this package-level resolver. + /// + /// # Errors + /// + /// Returns a typed error instead of coercing a missing, invalid, or float + /// reference into a state transition. + pub fn resolve_handler8( + &self, + instruction: &ScriptInstruction, + current_problem_record_index: u32, + ) -> Result { + let index = *instruction + .references + .first() + .ok_or(Handler8ResolveError::MissingReference)?; + let declaration = self.declarations.get(index as usize).ok_or( + Handler8ResolveError::VarSetIndexOutOfBounds { + index, + declarations: self.declarations.len(), + }, + )?; + let next_state = match declaration { + VarSetDeclaration { + type_name: VarSetType::Dword, + default_value: VarSetDefault::Dword(value), + .. + } => *value, + declaration => { + return Err(Handler8ResolveError::UnexpectedType { + found: declaration.type_name, + }); + } + }; + let reset = match next_state { + 2 => Handler8Reset::StateTwo, + 3 => Handler8Reset::StateThree, + _ => Handler8Reset::None, + }; + Ok(Handler8StateChange { + record_index: current_problem_record_index, + next_state, + reset, + }) + } + /// Resolves the fixed-width, corpus-proven operands of GOG `Handler(15)`. /// /// Static analysis of `ai.dll` at `0x10008054` shows four DWORD slots, @@ -914,9 +1033,9 @@ fn read_instruction( mod tests { use super::{ decode, decode_with_limits, parse_varset, Handler15ResolveError, Handler15TargetPayload, - Handler2RecordInput, Handler2RecordScheduler, Handler30ResolveError, - ScriptDispatchSelector, ScriptInstruction, VarSetDefault, VarSetError, VarSetType, - VmHostCallbackCommand, GOG_HANDLER_COUNT, INSTRUCTION_WORDS, + Handler2RecordInput, Handler2RecordScheduler, Handler30ResolveError, Handler8Reset, + Handler8ResolveError, ScriptDispatchSelector, ScriptInstruction, VarSetDefault, + VarSetError, VarSetType, VmHostCallbackCommand, GOG_HANDLER_COUNT, INSTRUCTION_WORDS, }; use fparkan_binary::{DecodeError, Limits}; @@ -1103,6 +1222,73 @@ STRING( 8, ignored, ignored, ignored)\r\n"; ); } + #[test] + fn handler_eight_resolves_dword_state_and_proven_reset_branches() { + let varset = parse_varset( + b"VAR( DWORD, solving, 1)\nVAR( DWORD, solved, 2)\nVAR( DWORD, state_three, 3)\n", + ) + .expect("valid Handler(8) varset"); + for (reference, reset) in [ + (0, Handler8Reset::None), + (1, Handler8Reset::StateTwo), + (2, Handler8Reset::StateThree), + ] { + let change = varset + .resolve_handler8( + &ScriptInstruction { + header_words: [8, 0, 0, 0, 0, 1, 0], + references: vec![reference], + }, + 17, + ) + .expect("typed Handler(8) state"); + assert_eq!(change.record_index, 17); + assert_eq!(change.next_state, reference + 1); + assert_eq!(change.reset, reset); + } + } + + #[test] + fn handler_eight_rejects_missing_float_and_out_of_range_operands() { + let empty = parse_varset(b"").expect("empty varset"); + assert_eq!( + empty.resolve_handler8( + &ScriptInstruction { + header_words: [8, 0, 0, 0, 0, 0, 0], + references: Vec::new(), + }, + 0, + ), + Err(Handler8ResolveError::MissingReference) + ); + let float = parse_varset(b"VAR( float, value, 1.0)\n").expect("float varset"); + assert_eq!( + float.resolve_handler8( + &ScriptInstruction { + header_words: [8, 0, 0, 0, 0, 1, 0], + references: vec![0], + }, + 0, + ), + Err(Handler8ResolveError::UnexpectedType { + found: VarSetType::Float, + }) + ); + assert_eq!( + float.resolve_handler8( + &ScriptInstruction { + header_words: [8, 0, 0, 0, 0, 1, 0], + references: vec![1], + }, + 0, + ), + Err(Handler8ResolveError::VarSetIndexOutOfBounds { + index: 1, + declarations: 1, + }) + ); + } + #[test] fn handler_fifteen_resolves_exact_typed_target_by_place_operands() { let varset = parse_varset( diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md index 11ff983..54d4e9f 100644 --- a/docs/appendices/script-vm.md +++ b/docs/appendices/script-vm.md @@ -173,6 +173,26 @@ layout prerequisites/modifiers/unlocks. Formula evaluator требует strict grammar/version, typed operands, deterministic numeric policy, bounded stack и явных errors; x87-compatible rounding нужен там, где оно выбирает ветку. +### Handler(8): problem-record state write + +`Handler(8)` is the ninth VM-table entry at GOG `ai.dll` VA `0x10009b0d`. +All 179 corpus records have exactly one in-range `DWORD` reference; the two +observed entries are `ST_SOLVING=1` (122 records) and `ST_SOLVED=2` (57). +The handler resolves loader-bound `dCurrentProblem` through varset index +`this+0x868`, uses that live DWORD as a bounds-checked index into a table at +`this+0xa0` with 100-byte records, then resolves the instruction's one DWORD +and writes it to the selected record at `+0x18`. + +The write has two statically proven exceptional branches. State `2` calls a +reset helper that zeroes record words `0..=3` and `6` before invoking two +opaque callback slots; state `3` does the same except word `3` is preserved. +Both then write `+0x18`. Every other state simply writes the state word. +`VarSet::resolve_handler8` emits a `Handler8StateChange` with the caller-owned +live record index, resolved state, and explicit reset kind. It does not invent +the table owner, the pre-reset helper, or callback semantics. Reproduce the +evidence with `ExportAiVmHandler8.java`, `ExportAiVmHandler8Callees.java`, and +`ExportAiVmHandler8Transitions.java`. + ### Handler(15): typed target-call boundary `Handler(15)` is the sixteenth VM-table entry at GOG `ai.dll` VA `0x10008054` diff --git a/tools/ghidra/ExportAiVmHandler8.java b/tools/ghidra/ExportAiVmHandler8.java new file mode 100644 index 0000000..4539629 --- /dev/null +++ b/tools/ghidra/ExportAiVmHandler8.java @@ -0,0 +1,23 @@ +// Emits Handler(8), 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 ExportAiVmHandler8 extends GhidraScript { + private static final long ADDRESS = 0x10009b0dL; + + @Override + public void run() throws Exception { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress(ADDRESS); + Function function = currentProgram.getFunctionManager().getFunctionAt(address); + println("===== AI VM Handler(8) ====="); + 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/ExportAiVmHandler8Callees.java b/tools/ghidra/ExportAiVmHandler8Callees.java new file mode 100644 index 0000000..6ddf317 --- /dev/null +++ b/tools/ghidra/ExportAiVmHandler8Callees.java @@ -0,0 +1,25 @@ +// Emits the two non-trivial local callees reached by Handler(8). 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 ExportAiVmHandler8Callees extends GhidraScript { + private static final long[] ADDRESSES = {0x10002e90L, 0x10005710L}; + + @Override + public void run() throws Exception { + DecompInterface decompiler = new DecompInterface(); + decompiler.openProgram(currentProgram); + for (long value : ADDRESSES) { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress(value); + Function function = currentProgram.getFunctionManager().getFunctionAt(address); + println("===== AI Handler(8) callee " + address + " ====="); + if (function == null) { println("missing"); continue; } + println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC()); + } + decompiler.dispose(); + } +} diff --git a/tools/ghidra/ExportAiVmHandler8Transitions.java b/tools/ghidra/ExportAiVmHandler8Transitions.java new file mode 100644 index 0000000..2eee9a8 --- /dev/null +++ b/tools/ghidra/ExportAiVmHandler8Transitions.java @@ -0,0 +1,25 @@ +// Emits the state-transition helpers selected by Handler(8). 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 ExportAiVmHandler8Transitions extends GhidraScript { + private static final long[] ADDRESSES = {0x10005010L, 0x10005040L}; + + @Override + public void run() throws Exception { + DecompInterface decompiler = new DecompInterface(); + decompiler.openProgram(currentProgram); + for (long value : ADDRESSES) { + Address address = currentProgram.getAddressFactory().getDefaultAddressSpace() + .getAddress(value); + Function function = currentProgram.getFunctionManager().getFunctionAt(address); + println("===== AI Handler(8) transition " + address + " ====="); + if (function == null) { println("missing"); continue; } + println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC()); + } + decompiler.dispose(); + } +}