aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-18 20:53:09 +0300
committerValentin Popov <valentin@popov.link>2026-07-18 20:53:09 +0300
commit5d40ba3968d73bbe08808ff0b64daf6661b137f8 (patch)
tree43af3426bb61611ece81790763fa58d20e7d7bac
parentd1edf4ba65e46546e4a2c1ef6792e68d19d247fd (diff)
downloadfparkan-5d40ba3968d73bbe08808ff0b64daf6661b137f8.tar.xz
fparkan-5d40ba3968d73bbe08808ff0b64daf6661b137f8.zip
feat(script): resolve handler eight states
-rw-r--r--crates/fparkan-script/src/lib.rs192
-rw-r--r--docs/appendices/script-vm.md20
-rw-r--r--tools/ghidra/ExportAiVmHandler8.java23
-rw-r--r--tools/ghidra/ExportAiVmHandler8Callees.java25
-rw-r--r--tools/ghidra/ExportAiVmHandler8Transitions.java25
5 files changed, 282 insertions, 3 deletions
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<Handler8StateChange, Handler8ResolveError> {
+ 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};
@@ -1104,6 +1223,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(
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",
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();
+ }
+}