feat(script): classify VM dispatch selectors
This commit is contained in:
@@ -48,7 +48,7 @@ const MISSION_INSPECT_SCHEMA: &str = "fparkan-mission-inspect-v1";
|
|||||||
const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1";
|
const TERRAIN_INSPECT_SCHEMA: &str = "fparkan-terrain-inspect-v1";
|
||||||
const MODEL_INSPECT_SCHEMA: &str = "fparkan-model-inspect-v1";
|
const MODEL_INSPECT_SCHEMA: &str = "fparkan-model-inspect-v1";
|
||||||
const WEAR_INSPECT_SCHEMA: &str = "fparkan-wear-inspect-v1";
|
const WEAR_INSPECT_SCHEMA: &str = "fparkan-wear-inspect-v1";
|
||||||
const SCRIPT_INSPECT_SCHEMA: &str = "fparkan-script-inspect-v1";
|
const SCRIPT_INSPECT_SCHEMA: &str = "fparkan-script-inspect-v2";
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct ArchiveInspectOutput<'a> {
|
struct ArchiveInspectOutput<'a> {
|
||||||
@@ -236,6 +236,13 @@ struct ScriptInspectOutput<'a> {
|
|||||||
instructions: usize,
|
instructions: usize,
|
||||||
references: usize,
|
references: usize,
|
||||||
trailing_bytes: usize,
|
trailing_bytes: usize,
|
||||||
|
first_header_word_candidates: Vec<ScriptHeaderWordCount>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ScriptHeaderWordCount {
|
||||||
|
value: u32,
|
||||||
|
instructions: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -335,6 +342,10 @@ fn script_inspect_json(
|
|||||||
.flat_map(|event| &event.instructions)
|
.flat_map(|event| &event.instructions)
|
||||||
.map(|instruction| instruction.references.len())
|
.map(|instruction| instruction.references.len())
|
||||||
.sum();
|
.sum();
|
||||||
|
let mut candidates = std::collections::BTreeMap::<u32, usize>::new();
|
||||||
|
for instruction in package.events.iter().flat_map(|event| &event.instructions) {
|
||||||
|
*candidates.entry(instruction.header_words[0]).or_insert(0) += 1;
|
||||||
|
}
|
||||||
serialize_json(&ScriptInspectOutput {
|
serialize_json(&ScriptInspectOutput {
|
||||||
schema_version: SCRIPT_INSPECT_SCHEMA,
|
schema_version: SCRIPT_INSPECT_SCHEMA,
|
||||||
path,
|
path,
|
||||||
@@ -343,6 +354,13 @@ fn script_inspect_json(
|
|||||||
instructions,
|
instructions,
|
||||||
references,
|
references,
|
||||||
trailing_bytes: package.trailing_bytes.len(),
|
trailing_bytes: package.trailing_bytes.len(),
|
||||||
|
first_header_word_candidates: candidates
|
||||||
|
.into_iter()
|
||||||
|
.map(|(value, instructions)| ScriptHeaderWordCount {
|
||||||
|
value,
|
||||||
|
instructions,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -856,7 +874,7 @@ mod tests {
|
|||||||
fparkan_script::decode(&[73, 0, 0, 0, 0, 0, 0, 0]).expect("minimal script package");
|
fparkan_script::decode(&[73, 0, 0, 0, 0, 0, 0, 0]).expect("minimal script package");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
script_inspect_json("script.scr", &package),
|
script_inspect_json("script.scr", &package),
|
||||||
Ok("{\"schema_version\":\"fparkan-script-inspect-v1\",\"path\":\"script.scr\",\"opcode_handler_count\":73,\"events\":0,\"instructions\":0,\"references\":0,\"trailing_bytes\":0}".to_string())
|
Ok("{\"schema_version\":\"fparkan-script-inspect-v2\",\"path\":\"script.scr\",\"opcode_handler_count\":73,\"events\":0,\"instructions\":0,\"references\":0,\"trailing_bytes\":0,\"first_header_word_candidates\":[]}".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use std::sync::Arc;
|
|||||||
|
|
||||||
const INSTRUCTION_HEADER_BYTES: u64 = 28;
|
const INSTRUCTION_HEADER_BYTES: u64 = 28;
|
||||||
const INSTRUCTION_WORDS: usize = 7;
|
const INSTRUCTION_WORDS: usize = 7;
|
||||||
|
const GOG_HANDLER_COUNT: u32 = 73;
|
||||||
|
|
||||||
/// A compiled `.scr` package.
|
/// A compiled `.scr` package.
|
||||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
@@ -50,6 +51,38 @@ pub struct ScriptInstruction {
|
|||||||
pub references: Vec<u32>,
|
pub references: Vec<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The recovered selector for an instruction's installed handler table.
|
||||||
|
///
|
||||||
|
/// The GOG AI loader copies 73 function pointers in order. Across all checked
|
||||||
|
/// GOG packages the first disk word is either one of these indices or the
|
||||||
|
/// explicit `u32::MAX` sentinel. This is a disassembly contract, not an
|
||||||
|
/// instruction executor.
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ScriptDispatchSelector {
|
||||||
|
/// One of the ordered handlers installed by `ai.dll`.
|
||||||
|
Handler(u8),
|
||||||
|
/// The on-disk `0xffff_ffff` sentinel.
|
||||||
|
Sentinel,
|
||||||
|
/// A value not yet observed or accepted by the GOG handler table.
|
||||||
|
Unknown(u32),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ScriptInstruction {
|
||||||
|
/// Returns the recovered dispatch selector from the first disk word.
|
||||||
|
#[must_use]
|
||||||
|
pub fn dispatch_selector(&self) -> ScriptDispatchSelector {
|
||||||
|
match self.header_words[0] {
|
||||||
|
value if value < GOG_HANDLER_COUNT =>
|
||||||
|
{
|
||||||
|
#[allow(clippy::cast_possible_truncation)]
|
||||||
|
ScriptDispatchSelector::Handler(self.header_words[0] as u8)
|
||||||
|
}
|
||||||
|
u32::MAX => ScriptDispatchSelector::Sentinel,
|
||||||
|
value => ScriptDispatchSelector::Unknown(value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Decodes a compiled AI package using default safety limits.
|
/// Decodes a compiled AI package using default safety limits.
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
@@ -172,7 +205,9 @@ fn read_instruction(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{decode, decode_with_limits};
|
use super::{
|
||||||
|
decode, decode_with_limits, ScriptDispatchSelector, GOG_HANDLER_COUNT, INSTRUCTION_WORDS,
|
||||||
|
};
|
||||||
use fparkan_binary::{DecodeError, Limits};
|
use fparkan_binary::{DecodeError, Limits};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -203,6 +238,10 @@ mod tests {
|
|||||||
[1, 2, 3, 4, 5, 2, 6]
|
[1, 2, 3, 4, 5, 2, 6]
|
||||||
);
|
);
|
||||||
assert_eq!(package.events[0].instructions[0].references, [7, 8]);
|
assert_eq!(package.events[0].instructions[0].references, [7, 8]);
|
||||||
|
assert_eq!(
|
||||||
|
package.events[0].instructions[0].dispatch_selector(),
|
||||||
|
ScriptDispatchSelector::Handler(1)
|
||||||
|
);
|
||||||
assert_eq!(package.trailing_bytes, [0xaa, 0xbb]);
|
assert_eq!(package.trailing_bytes, [0xaa, 0xbb]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,4 +290,21 @@ mod tests {
|
|||||||
Err(DecodeError::LimitExceeded { .. })
|
Err(DecodeError::LimitExceeded { .. })
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_selector_preserves_sentinel_and_unobserved_values() {
|
||||||
|
let mut instruction = super::ScriptInstruction {
|
||||||
|
header_words: [u32::MAX; INSTRUCTION_WORDS],
|
||||||
|
references: Vec::new(),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
instruction.dispatch_selector(),
|
||||||
|
ScriptDispatchSelector::Sentinel
|
||||||
|
);
|
||||||
|
instruction.header_words[0] = GOG_HANDLER_COUNT;
|
||||||
|
assert_eq!(
|
||||||
|
instruction.dispatch_selector(),
|
||||||
|
ScriptDispatchSelector::Unknown(GOG_HANDLER_COUNT)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,15 @@ cargo run -p fparkan-cli -- script inspect `
|
|||||||
records, 20 references и 0 trailing bytes. Это corpus evidence для reader-а,
|
records, 20 references и 0 trailing bytes. Это corpus evidence для reader-а,
|
||||||
но не разрешение на исполнение неизвестных 73 opcodes.
|
но не разрешение на исполнение неизвестных 73 opcodes.
|
||||||
|
|
||||||
|
Теперь установлен selector: loader `0x10001000` создаёт 73 pointers в
|
||||||
|
фиксированном порядке, а `0x10011e70` копирует их без перестановки в runtime
|
||||||
|
array. Во всех 58 GOG `.scr` первый header word каждого nested record равен
|
||||||
|
`0..72` либо `0xffff_ffff`: соответственно 2095 handler selectors и 3992
|
||||||
|
sentinel records. Поэтому `ScriptInstruction::dispatch_selector()` возвращает
|
||||||
|
`Handler(0..72)`, `Sentinel` или сохраняемый `Unknown(u32)`. Первый handler
|
||||||
|
(`Handler(0)`, VA `0x10008034`) только устанавливает current context и flag
|
||||||
|
`+0x50 = 1`; это не даёт ему игрового имени и не заменяет runtime trace.
|
||||||
|
|
||||||
TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не
|
TMA properties остаются four raw `u32` words плюс имя, пока consumer/schema не
|
||||||
задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе
|
задаст тип (integer/float bits/ObjectId/enum/fixed-point/index). В том числе
|
||||||
сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state,
|
сохраняются `NOT USED`; corpus подтверждает `Invulnerability`, life state,
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ references каждого record, жёстко ограничивает counts/a
|
|||||||
файла, но не таблица семантик: названия opcode/words появятся только после
|
файла, но не таблица семантик: названия opcode/words появятся только после
|
||||||
handler contracts и runtime traces.
|
handler contracts и runtime traces.
|
||||||
|
|
||||||
|
Связь первого header word с dispatch теперь доказана статически: `ai.dll`
|
||||||
|
создаёт 73 handler pointers в известном порядке и копирует table без
|
||||||
|
перестановки. По всем 58 GOG packages первый word — индекс `0..72` либо
|
||||||
|
`0xffff_ffff` sentinel; `fparkan-script` отражает это как typed
|
||||||
|
`ScriptDispatchSelector`, сохраняя неожиданные значения `Unknown`. Первый
|
||||||
|
handler лишь инициирует execution context (`+0x50 = 1`); его gameplay meaning
|
||||||
|
пока не назван.
|
||||||
|
|
||||||
Безопасная runtime-модель:
|
Безопасная runtime-модель:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Emits the first function in the AI DLL's verified 73-entry VM handler table.
|
||||||
|
// Run through Ghidra headless analysis; 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 ExportAiVmHandler0 extends GhidraScript {
|
||||||
|
private static final long ADDRESS = 0x10008034L;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() throws Exception {
|
||||||
|
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||||
|
.getAddress(ADDRESS);
|
||||||
|
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||||
|
println("===== AI VM handler 0 =====");
|
||||||
|
if (function == null) { println("missing"); return; }
|
||||||
|
DecompInterface decompiler = new DecompInterface();
|
||||||
|
decompiler.openProgram(currentProgram);
|
||||||
|
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||||
|
decompiler.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
// Emits the routine that receives the AI VM's verified 73-entry handler table.
|
||||||
|
// Run through Ghidra headless analysis; 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 ExportAiVmTableInstall extends GhidraScript {
|
||||||
|
private static final long ADDRESS = 0x10011E70L;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() throws Exception {
|
||||||
|
Address address = currentProgram.getAddressFactory().getDefaultAddressSpace()
|
||||||
|
.getAddress(ADDRESS);
|
||||||
|
Function function = currentProgram.getFunctionManager().getFunctionAt(address);
|
||||||
|
println("===== AI VM handler table install =====");
|
||||||
|
if (function == null) { println("missing"); return; }
|
||||||
|
DecompInterface decompiler = new DecompInterface();
|
||||||
|
decompiler.openProgram(currentProgram);
|
||||||
|
println(decompiler.decompileFunction(function, 60, monitor).getDecompiledFunction().getC());
|
||||||
|
decompiler.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user