aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-07-18 20:33:14 +0300
committerValentin Popov <valentin@popov.link>2026-07-18 20:33:14 +0300
commit24ba0bdd3756c2f84f27ae0fd70485f930bd991b (patch)
tree98c5ee49708c73653e16e50e2316da5872631ffa
parentea09804778ba7b1855b37fa21865bd6762e81638 (diff)
downloadfparkan-24ba0bdd3756c2f84f27ae0fd70485f930bd991b.tar.xz
fparkan-24ba0bdd3756c2f84f27ae0fd70485f930bd991b.zip
feat(script): resolve handler thirty dword callbacks
-rw-r--r--crates/fparkan-script/src/lib.rs167
-rw-r--r--docs/appendices/script-vm.md17
-rw-r--r--tools/ghidra/ExportAiVarSetParser.java23
-rw-r--r--tools/ghidra/ExportAiVarSetU32Resolver.java23
4 files changed, 225 insertions, 5 deletions
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<VmHostCallbackCommand, Handler30ResolveError> {
+ 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<u32, Handler30ResolveError> {
+ 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};
@@ -719,6 +829,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();
bytes.extend_from_slice(&73_u32.to_le_bytes());
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();
+ }
+}