aboutsummaryrefslogtreecommitdiff
path: root/crates
diff options
context:
space:
mode:
authorValentin Popov <valentin@popov.link>2026-10-31 02:00:05 +0300
committerValentin Popov <valentin@popov.link>2026-10-31 02:00:05 +0300
commitdbb59b00d13d18a8c94b2bdacd6c14b4dc20df29 (patch)
tree629fdeb957beed3f41f4c8700a5bf3df40fe1bcc /crates
parent7f8a37d22abbedf487666132a4857e13c213c944 (diff)
downloadfparkan-dbb59b00d13d18a8c94b2bdacd6c14b4dc20df29.tar.xz
fparkan-dbb59b00d13d18a8c94b2bdacd6c14b4dc20df29.zip
feat(script): execute numeric FML assignments during mission Init
Diffstat (limited to 'crates')
-rw-r--r--crates/fparkan-runtime/src/lib.rs616
-rw-r--r--crates/fparkan-script/src/lib.rs1012
2 files changed, 1431 insertions, 197 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index 086966f..5595b28 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -22,8 +22,9 @@ use fparkan_prototype::{
};
use fparkan_resource::{resource_name, CachedResourceRepository, ResourceKey, ResourceRepository};
use fparkan_script::{
- Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError,
- ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand,
+ FormulaTable, Handler19InitInput, ScriptInitResolution, ScriptInitResolveError,
+ ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSet, VarSetDefault,
+ VmHostCallbackCommand,
};
use fparkan_terrain::SurfaceQuery;
use fparkan_vfs::{Vfs, VfsError};
@@ -657,9 +658,11 @@ pub struct MissionScriptBundle {
pub base_path: String,
/// Resolved compiled `.scr` path.
pub script_path: String,
- /// Losslessly decoded compiled package; only proven `Handler(19)` Init
- /// writes are resolved and retained by runtime.
+ /// Losslessly decoded compiled package; only the recovered first-`Init`
+ /// subset is resolved and retained by runtime.
pub package: ScriptPackage,
+ /// Optional formula rows loaded from this bundle's sibling `.fml` file.
+ pub formula_table: Option<FormulaTable>,
/// Outcome of the restricted first-`Init` resolution for this bundle.
pub init_execution: MissionScriptInitExecution,
/// Recovered per-AI Behavior scheduler and total state.
@@ -725,11 +728,12 @@ fn schedule_behavior_distributor_tick(
Some(behavior_elapsed_seconds(elapsed_ms))
}
-/// Numeric script defaults selected for the mission's first script bundle.
+/// Numeric script declarations selected for the mission's first script bundle.
///
/// GOG `ai.dll` first tries `<bundle-base>.var`, then falls back to the shared
/// `MISSIONS/SCRIPTS/varset.var`. This stores the successfully selected source
-/// and supplies the typed targets for the proven `Handler(19)` Init writes.
+/// and supplies typed targets for recovered `Handler(19)` and numeric formula
+/// assignments in the first `Init` event.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionScriptVarSet {
/// Resolved `.var` path selected by the original fallback order.
@@ -740,11 +744,7 @@ pub struct MissionScriptVarSet {
pub declarations: VarSet,
}
-/// One resolved, corpus-proven `Init` result for a mission clan.
-///
-/// This is intentionally limited to `Handler(19)`: an `Init` event's other
-/// selectors are retained in [`MissionScriptBundle::package`] but are not
-/// guessed as executable behavior.
+/// One resolved subset of the first `Init` event for a mission clan.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionScriptInitState {
/// TMA clan index that owns the `SuperAI` instance and varset.
@@ -753,8 +753,8 @@ pub struct MissionScriptInitState {
pub event_index: usize,
/// Raw event word preserved from the compiled `Init` event.
pub event_word: u32,
- /// Exact three DWORD writes made by each proven `Handler(19)` instruction.
- pub writes: Vec<Handler19DwordWrite>,
+ /// Ordered numeric writes made by the supported `Init` instructions.
+ pub writes: Vec<ScriptInitWrite>,
}
/// Execution status of the first event whose case-sensitive C-string name is `Init`.
@@ -768,14 +768,14 @@ pub enum MissionScriptInitExecution {
event_index: usize,
},
/// A preview retained the decoded package but stopped before the first
- /// instruction outside the recovered `Handler(19)` action subset.
+ /// instruction outside the recovered numeric `Init` subset.
Unsupported {
/// Zero-based event table index selected by native lookup.
event_index: usize,
/// Zero-based instruction index where restricted resolution stopped.
instruction_index: usize,
- /// Typed selector, control, or result-save shape that is not modeled.
- error: Handler19ResolveError,
+ /// Typed selector, operand, or expression shape that is not modeled.
+ error: ScriptInitResolveError,
},
}
@@ -918,7 +918,7 @@ pub struct LoadedMission {
pub script_event_count: usize,
/// Numeric script defaults loaded through the original bundle/fallback rule.
pub script_varset_declaration_count: usize,
- /// `Handler(19)` Init results resolved for mission clans.
+ /// Writes resolved for the first mission-clan `Init` events.
pub script_init_state_count: usize,
/// Instantiated per-clan varsets whose Init writes have been applied.
pub script_varset_state_count: usize,
@@ -997,7 +997,7 @@ pub enum EngineError {
/// Parse diagnostic.
message: String,
},
- /// A recovered `Handler(19)` Init input or target was not representable.
+ /// A recovered `Init` input or write target was not representable.
ScriptInit {
/// Owning TMA clan index.
clan_index: usize,
@@ -1015,7 +1015,7 @@ pub enum EngineError {
/// Zero-based instruction index within that event.
instruction_index: usize,
/// Typed unsupported instruction shape.
- source: Handler19ResolveError,
+ source: ScriptInitResolveError,
},
/// A recovered script handler could not resolve against runtime state.
ScriptRuntime {
@@ -1831,10 +1831,11 @@ pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVar
.and_then(|state| state.script_varset.as_ref())
}
-/// Returns the proven script `Init` writes resolved for the loaded mission.
+/// Returns the recovered script `Init` writes resolved for the loaded mission.
///
-/// The list has one entry for each clan whose `Init` event contains recovered
-/// `Handler(19)` instructions. It is not a general script event executor.
+/// The list has one entry for each clan whose first `Init` event contains a
+/// supported `Handler(19)` instruction or numeric FML literal assignment. It
+/// is not a general script event executor.
#[must_use]
pub fn loaded_mission_script_init_states(engine: &Engine) -> Option<&[MissionScriptInitState]> {
engine
@@ -6335,6 +6336,37 @@ fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPa
})
}
+fn script_bundle_stem(base_path: &str) -> &str {
+ let suffix_start = base_path.len().saturating_sub(4);
+ if base_path
+ .get(suffix_start..)
+ .is_some_and(|suffix| suffix.eq_ignore_ascii_case(".scr"))
+ {
+ &base_path[..suffix_start]
+ } else {
+ base_path
+ }
+}
+
+fn script_sibling_path(base_path: &str, extension: &str) -> String {
+ format!("{}.{extension}", script_bundle_stem(base_path))
+}
+
+fn load_script_formula_table(
+ vfs: &Arc<dyn Vfs>,
+ base_path: &str,
+) -> Result<Option<FormulaTable>, EngineError> {
+ let path = normalize_engine_path("mission formula", &script_sibling_path(base_path, "fml"))?;
+ match vfs.read(&path) {
+ Ok(bytes) => Ok(Some(FormulaTable::parse(&bytes))),
+ Err(VfsError::NotFound(_)) => Ok(None),
+ Err(source) => Err(EngineError::Vfs {
+ path: path.as_str().to_string(),
+ source,
+ }),
+ }
+}
+
fn load_mission_scripts(
vfs: &Arc<dyn Vfs>,
mission: &MissionDocument,
@@ -6356,6 +6388,7 @@ fn load_mission_scripts(
path: normalized.as_str().to_string(),
message: source.to_string(),
})?;
+ let formula_table = load_script_formula_table(vfs, &base_path)?;
if first_base_path.is_none() {
first_base_path = Some(base_path.clone());
}
@@ -6364,6 +6397,7 @@ fn load_mission_scripts(
base_path,
script_path: normalized.as_str().to_string(),
package,
+ formula_table,
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
});
@@ -6375,7 +6409,7 @@ fn load_mission_scripts(
let (init_states, varset_states) = if asset_scope == MissionAssetScope::Full {
(Vec::new(), Vec::new())
} else if let Some(varset) = &varset {
- let init_states = resolve_handler19_init_states(
+ let init_states = resolve_init_states(
&mission
.clans
.iter()
@@ -6420,7 +6454,7 @@ fn start_mission_scripts(
));
if let Some(varset) = scripts.varset.as_ref() {
- init_states.extend(resolve_handler19_init_states(
+ init_states.extend(resolve_init_states(
&clan_anchors,
std::slice::from_mut(bundle),
varset,
@@ -6462,12 +6496,12 @@ fn materialize_script_varset_states(
return Err(EngineError::ScriptInit {
clan_index: bundle.clan_index,
message: format!(
- "Handler(19) write index {} is outside the instantiated varset",
+ "Init write index {} is outside the instantiated varset",
write.index
),
});
};
- *value = VarSetDefault::Dword(write.value);
+ *value = write.value;
}
}
Ok(MissionScriptVarSetState {
@@ -6478,7 +6512,7 @@ fn materialize_script_varset_states(
.collect()
}
-fn resolve_handler19_init_states(
+fn resolve_init_states(
clan_anchors: &[[f32; 2]],
bundles: &mut [MissionScriptBundle],
varset: &MissionScriptVarSet,
@@ -6511,15 +6545,15 @@ fn resolve_handler19_init_states(
};
match varset
.declarations
- .resolve_handler19_init(&bundle.package, input)
+ .resolve_init(&bundle.package, input, bundle.formula_table.as_ref())
.map_err(|source| EngineError::ScriptInit {
clan_index: bundle.clan_index,
message: source.to_string(),
})? {
- Handler19InitResolution::Missing => {
+ ScriptInitResolution::Missing => {
bundle.init_execution = MissionScriptInitExecution::Missing;
}
- Handler19InitResolution::Executed {
+ ScriptInitResolution::Executed {
event_index,
event_word,
writes,
@@ -6534,7 +6568,7 @@ fn resolve_handler19_init_states(
});
}
}
- Handler19InitResolution::Unsupported {
+ ScriptInitResolution::Unsupported {
event_index,
instruction_index,
error,
@@ -6545,7 +6579,7 @@ fn resolve_handler19_init_states(
error,
};
}
- Handler19InitResolution::Unsupported {
+ ScriptInitResolution::Unsupported {
event_index,
instruction_index,
error,
@@ -9626,6 +9660,7 @@ mod tests {
trailing_bytes: Vec::new(),
raw: Arc::from(&b""[..]),
},
+ formula_table: None,
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
};
@@ -9642,7 +9677,7 @@ mod tests {
assert!(matches!(
error,
EngineError::ScriptInitUnsupported {
- source: Handler19ResolveError::UnsupportedSelector {
+ source: ScriptInitResolveError::UnsupportedSelector {
found: fparkan_script::ScriptDispatchSelector::Handler(18),
},
..
@@ -12213,6 +12248,204 @@ mod tests {
}
#[test]
+ fn full_load_applies_a_synthetic_numeric_formula_in_init() {
+ fn push_u32(bytes: &mut Vec<u8>, value: u32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_i32(bytes: &mut Vec<u8>, value: i32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_f32(bytes: &mut Vec<u8>, value: f32) {
+ bytes.extend_from_slice(&value.to_le_bytes());
+ }
+
+ fn push_lp(bytes: &mut Vec<u8>, value: &[u8]) {
+ push_u32(bytes, u32::try_from(value.len()).expect("length fits u32"));
+ bytes.extend_from_slice(value);
+ }
+
+ fn build_nres(entries: &[(u32, u32, u32, Vec<u8>)]) -> Vec<u8> {
+ let mut bytes = vec![0; 16];
+ let mut offsets = Vec::with_capacity(entries.len());
+ for (_, _, _, payload) in entries {
+ offsets.push(u32::try_from(bytes.len()).expect("offset fits u32"));
+ bytes.extend_from_slice(payload);
+ let padding = (8 - bytes.len() % 8) % 8;
+ bytes.resize(bytes.len() + padding, 0);
+ }
+ for (index, (type_id, attr1, attr3, payload)) in entries.iter().enumerate() {
+ push_u32(&mut bytes, *type_id);
+ push_u32(&mut bytes, *attr1);
+ push_u32(&mut bytes, 0);
+ push_u32(
+ &mut bytes,
+ u32::try_from(payload.len()).expect("payload fits u32"),
+ );
+ push_u32(&mut bytes, *attr3);
+ let mut name = [0; 36];
+ name[..4].copy_from_slice(b"Test");
+ bytes.extend_from_slice(&name);
+ push_u32(&mut bytes, offsets[index]);
+ push_u32(&mut bytes, u32::try_from(index).expect("index fits u32"));
+ }
+ bytes[..4].copy_from_slice(b"NRes");
+ bytes[4..8].copy_from_slice(&0x100_u32.to_le_bytes());
+ bytes[8..12].copy_from_slice(
+ &u32::try_from(entries.len())
+ .expect("count fits u32")
+ .to_le_bytes(),
+ );
+ let size = u32::try_from(bytes.len()).expect("size fits u32");
+ bytes[12..16].copy_from_slice(&size.to_le_bytes());
+ bytes
+ }
+
+ fn empty_land_mesh() -> Vec<u8> {
+ let streams = [
+ (1, 0, 38, Vec::new()),
+ (2, 0, 0, vec![0; 0x8c]),
+ (3, 0, 12, Vec::new()),
+ (4, 0, 4, Vec::new()),
+ (5, 0, 4, Vec::new()),
+ (18, 0, 4, Vec::new()),
+ (14, 0, 4, Vec::new()),
+ (11, 0, 4, Vec::new()),
+ (21, 0, 28, Vec::new()),
+ ];
+ build_nres(&streams)
+ }
+
+ fn minimal_land_map() -> Vec<u8> {
+ let mut payload = Vec::new();
+ for value in [0.0_f32, 0.0, 0.0, 0.0, 2.0, 0.0, 1.0, 0.0] {
+ push_f32(&mut payload, value);
+ }
+ for value in [0, 0, 7, 0, 2, 0] {
+ push_u32(&mut payload, value);
+ }
+ for value in [0.0_f32, 0.0, 0.0, 1.0, 0.0, 0.0] {
+ push_f32(&mut payload, value);
+ }
+ for _ in 0..2 {
+ push_i32(&mut payload, -1);
+ push_i32(&mut payload, -1);
+ }
+ push_u32(&mut payload, 1);
+ push_u32(&mut payload, 1);
+ payload.extend_from_slice(&1_u16.to_le_bytes());
+ payload.extend_from_slice(&0_u16.to_le_bytes());
+ build_nres(&[(12, 1, 0, payload)])
+ }
+
+ fn synthetic_mission() -> Vec<u8> {
+ let mut bytes = Vec::new();
+ push_u32(&mut bytes, 1);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, 6);
+ push_u32(&mut bytes, 1);
+ push_lp(&mut bytes, b"Synthetic");
+ push_u32(&mut bytes, u32::MAX);
+ push_f32(&mut bytes, 12.25);
+ push_f32(&mut bytes, 34.75);
+ push_u32(&mut bytes, 1);
+ push_lp(&mut bytes, b"MISSIONS/SCRIPTS/formula");
+ push_i32(&mut bytes, 0);
+ push_lp(&mut bytes, b"");
+ push_i32(&mut bytes, 0);
+ push_u32(&mut bytes, 0);
+ push_u32(&mut bytes, 10);
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"DATA\\MAPS\\SYNTHETIC\\land");
+ push_u32(&mut bytes, 0);
+ push_lp(&mut bytes, b"");
+ push_u32(&mut bytes, 1);
+ push_u32(&mut bytes, 0);
+ bytes
+ }
+
+ let package = ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![fparkan_script::ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![
+ ScriptInstruction {
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
+ references: vec![0, 1, 2],
+ },
+ ScriptInstruction {
+ header_words: [u32::MAX, 3, u32::MAX, u32::MAX, 6, 0, 0],
+ references: Vec::new(),
+ },
+ ],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: Arc::from(&b""[..]),
+ };
+ let mut files = MemoryVfs::default();
+ for (path, bytes) in [
+ (
+ "MISSIONS/TEST/data.tma",
+ Arc::<[u8]>::from(synthetic_mission().into_boxed_slice()),
+ ),
+ (
+ "DATA/MAPS/SYNTHETIC/Land.msh",
+ Arc::<[u8]>::from(empty_land_mesh().into_boxed_slice()),
+ ),
+ (
+ "DATA/MAPS/SYNTHETIC/Land.map",
+ Arc::<[u8]>::from(minimal_land_map().into_boxed_slice()),
+ ),
+ ("BuildDat.lst", Arc::from(&b""[..])),
+ (
+ "MISSIONS/SCRIPTS/formula.scr",
+ Arc::<[u8]>::from(encode_script_package(&package).into_boxed_slice()),
+ ),
+ (
+ "MISSIONS/SCRIPTS/formula.var",
+ Arc::from(
+ &b"VAR(DWORD, ClanBaseX, 0)\nVAR(DWORD, ClanBaseY, 0)\nVAR(DWORD, ClanID, 0)\nVAR(float, target, 0.5)\n"[..],
+ ),
+ ),
+ (
+ "MISSIONS/SCRIPTS/formula.fml",
+ Arc::from(&b"// authored literal\n\nFUNCTION( , 0.8, )\nFUNCTION( later, target + 2, )\n"[..]),
+ ),
+ ] {
+ files.insert(
+ normalize_engine_path("synthetic mission", path).expect("valid path"),
+ bytes,
+ );
+ }
+ let vfs: Arc<dyn Vfs> = Arc::new(files);
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
+ let loaded = load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ 0,
+ )
+ .expect("full load applies the authored numeric literal");
+
+ assert_eq!(loaded.script_bundle_count, 1);
+ let states = loaded_mission_script_varset_states(&engine).expect("runtime varset");
+ assert_eq!(
+ states[0].values,
+ [
+ VarSetDefault::Dword(12),
+ VarSetDefault::Dword(35),
+ VarSetDefault::Dword(0),
+ VarSetDefault::FloatBits(0x3f4c_cccd),
+ ]
+ );
+ }
+
+ #[test]
fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() {
let mut personal_files = MemoryVfs::default();
personal_files.insert(
@@ -12262,6 +12495,27 @@ mod tests {
}
#[test]
+ fn script_formula_table_uses_an_optional_case_insensitive_scr_sibling() {
+ let mut files = MemoryVfs::default();
+ let vfs: Arc<dyn Vfs> = Arc::new(files.clone());
+ assert!(matches!(
+ load_script_formula_table(&vfs, "MISSIONS/SCRIPTS/custom.SCR"),
+ Ok(None)
+ ));
+
+ files.insert(
+ normalize_engine_path("test", "MISSIONS/SCRIPTS/custom.fml").expect("path"),
+ Arc::from(&b"FUNCTION(value, 1.25, )\n"[..]),
+ );
+ let vfs: Arc<dyn Vfs> = Arc::new(files);
+ let formulas = load_script_formula_table(&vfs, "MISSIONS/SCRIPTS/custom.SCR")
+ .expect("load optional formula sibling")
+ .expect("formula file exists");
+ assert_eq!(formulas.rows.len(), 1);
+ assert_eq!(formulas.evaluate_numeric_literal(0), Ok(1.25));
+ }
+
+ #[test]
fn handler_nineteen_init_executes_from_clan_anchor_for_each_bundle() {
let declarations = fparkan_script::parse_varset(
b"VAR( DWORD, ClanBaseX, 950)\nVAR( DWORD, ClanBaseY, 1000)\nVAR( DWORD, ClanID, 0)\n",
@@ -12290,11 +12544,12 @@ mod tests {
trailing_bytes: Vec::new(),
raw: Arc::from(&b""[..]),
},
+ formula_table: None,
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
}];
- let states = resolve_handler19_init_states(
+ let states = resolve_init_states(
&[[500.0, 752.0], [728.0, 449.0]],
&mut bundles,
&varset,
@@ -12308,15 +12563,18 @@ mod tests {
event_index: 0,
event_word: 0x1234_5678,
writes: vec![
- Handler19DwordWrite {
+ ScriptInitWrite {
index: 0,
- value: 728
+ value: VarSetDefault::Dword(728),
},
- Handler19DwordWrite {
+ ScriptInitWrite {
index: 1,
- value: 449
+ value: VarSetDefault::Dword(449),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(1),
},
- Handler19DwordWrite { index: 2, value: 1 },
],
}]
);
@@ -12387,12 +12645,13 @@ mod tests {
base_path: "MISSIONS/SCRIPTS/default".to_string(),
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
package: package.clone(),
+ formula_table: None,
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
};
let anchors = [[500.0, 752.0]];
let mut preview_bundles = vec![bundle()];
- let preview_states = resolve_handler19_init_states(
+ let preview_states = resolve_init_states(
&anchors,
&mut preview_bundles,
&varset,
@@ -12405,7 +12664,7 @@ mod tests {
MissionScriptInitExecution::Unsupported {
event_index: 0,
instruction_index: 1,
- error: Handler19ResolveError::UnsupportedSelector {
+ error: ScriptInitResolveError::UnsupportedSelector {
found: ScriptDispatchSelector::Handler(18),
},
}
@@ -12426,7 +12685,7 @@ mod tests {
let mut full_bundles = vec![bundle()];
assert!(matches!(
- resolve_handler19_init_states(
+ resolve_init_states(
&anchors,
&mut full_bundles,
&varset,
@@ -12436,7 +12695,7 @@ mod tests {
clan_index: 0,
event_index: 0,
instruction_index: 1,
- source: Handler19ResolveError::UnsupportedSelector {
+ source: ScriptInitResolveError::UnsupportedSelector {
found: ScriptDispatchSelector::Handler(18),
},
..
@@ -12452,7 +12711,7 @@ mod tests {
..bundle()
}];
assert!(matches!(
- resolve_handler19_init_states(
+ resolve_init_states(
&anchors,
&mut malformed_bundle,
&varset,
@@ -13731,16 +13990,17 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
- fn licensed_campaign_preview_keeps_unsupported_init_without_partial_writes() {
+ fn licensed_campaign_full_load_applies_literals_then_single_handler43_rolls_back() {
let root = licensed_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
- let mut engine = create(EngineServices::new(vfs)).expect("engine");
+ let mut engine = create(EngineServices::new(vfs.clone())).expect("engine");
+ let campaign_request = || MissionRequest {
+ key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ };
let loaded = load_mission_static_preview_roots(
&mut engine,
- MissionRequest {
- key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
- mode: MissionMode::SingleMission,
- },
+ campaign_request(),
NonZeroUsize::new(1).expect("non-zero root count"),
)
.expect("load licensed campaign preview");
@@ -13759,42 +14019,252 @@ mod tests {
.iter()
.all(|bundle| bundle.behavior_warm_start.is_none()));
assert_eq!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE);
- let unsupported = bundles
+ let bundle = bundles
.iter()
- .find(|bundle| {
- matches!(
- &bundle.init_execution,
- MissionScriptInitExecution::Unsupported { .. }
- )
- })
- .expect("campaign package with an unsupported Init instruction");
+ .find(|bundle| bundle.clan_index == 0)
+ .expect("first campaign script bundle");
assert_eq!(
- &unsupported.init_execution,
- &MissionScriptInitExecution::Unsupported {
- event_index: 0,
- instruction_index: 1,
- error: Handler19ResolveError::UnsupportedSelector {
- found: fparkan_script::ScriptDispatchSelector::Sentinel,
- },
- }
+ bundle.base_path.replace('\\', "/"),
+ "MISSIONS/SCRIPTS/tut1_pl2"
+ );
+ assert_eq!(bundle.formula_table.as_ref().unwrap().rows.len(), 26);
+ assert_eq!(
+ bundle
+ .formula_table
+ .as_ref()
+ .unwrap()
+ .evaluate_numeric_literal(0),
+ Ok(0.0)
+ );
+ assert_eq!(
+ bundle.init_execution,
+ MissionScriptInitExecution::Executed { event_index: 0 }
);
- let clan_index = unsupported.clan_index;
let states = loaded_mission_script_init_states(&engine).expect("Init writes");
- assert!(states.iter().all(|state| state.clan_index != clan_index));
+ let init = states
+ .iter()
+ .find(|state| state.clan_index == 0)
+ .expect("first clan Init writes");
+ assert_eq!(
+ init.writes
+ .iter()
+ .map(|write| write.index)
+ .collect::<Vec<_>>(),
+ [224, 225, 226, 188, 189, 190, 191]
+ );
+ assert!(init.writes[3..]
+ .iter()
+ .all(|write| write.value == VarSetDefault::Dword(0)));
let varset = loaded_mission_script_varset_states(&engine)
.expect("per-clan varsets")
.iter()
- .find(|state| state.clan_index == clan_index)
- .expect("unsupported clan varset");
+ .find(|state| state.clan_index == 0)
+ .expect("first clan varset");
+ assert_eq!(
+ &varset.values[188..192],
+ &[
+ VarSetDefault::Dword(0),
+ VarSetDefault::Dword(0),
+ VarSetDefault::Dword(0),
+ VarSetDefault::Dword(0),
+ ]
+ );
assert_eq!(
&varset.values[224..227],
&[
- VarSetDefault::Dword(950),
- VarSetDefault::Dword(1000),
+ VarSetDefault::Dword(549),
+ VarSetDefault::Dword(630),
VarSetDefault::Dword(0),
]
);
assert!(loaded.script_bundle_count >= 1);
+
+ let full = load_mission(&mut engine, campaign_request(), 100_000)
+ .expect("full load licensed campaign mission");
+ assert_eq!(full.script_bundle_count, 4);
+ assert_eq!(full.script_init_state_count, 4);
+ assert_eq!(full.script_varset_state_count, 4);
+ let full_bundles = loaded_mission_script_bundles(&engine).expect("full script bundles");
+ assert_eq!(full_bundles.len(), 4);
+ assert!(full_bundles.iter().all(|bundle| matches!(
+ &bundle.init_execution,
+ MissionScriptInitExecution::Executed { event_index: 0 }
+ )));
+ assert!(full_bundles
+ .iter()
+ .all(|bundle| bundle.behavior_warm_start.is_some()));
+ let full_states = loaded_mission_script_init_states(&engine).expect("full Init writes");
+ let first_full_init = full_states
+ .iter()
+ .find(|state| state.clan_index == 0)
+ .expect("campaign clan 0 Init");
+ assert_eq!(
+ first_full_init
+ .writes
+ .iter()
+ .map(|write| write.index)
+ .collect::<Vec<_>>(),
+ [224, 225, 226, 188, 189, 190, 191]
+ );
+ assert!(first_full_init.writes[3..]
+ .iter()
+ .all(|write| write.value == VarSetDefault::Dword(0)));
+ let campaign_summary = loaded_mission(&engine).expect("full campaign").clone();
+ let campaign_bundles = full_bundles.to_vec();
+ let campaign_init_states = full_states.to_vec();
+ let campaign_varsets = loaded_mission_script_varset_states(&engine)
+ .expect("full per-clan varsets")
+ .to_vec();
+ let campaign_rng = engine.behavior_rng_state;
+ let before = step_headless(&mut engine, InputSnapshot, 0)
+ .expect("step loaded campaign")
+ .snapshot
+ .objects;
+
+ let single_error = load_mission(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Single.01/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ 100_500,
+ )
+ .expect_err("Single.01 reaches the later Handler(43) boundary");
+ assert!(matches!(
+ single_error,
+ EngineError::ScriptInitUnsupported {
+ clan_index: 1,
+ path,
+ event_index: 0,
+ instruction_index: 10,
+ source: ScriptInitResolveError::UnsupportedSelector {
+ found: fparkan_script::ScriptDispatchSelector::Handler(43),
+ },
+ } if path.ends_with("scream.scr")
+ ));
+ assert_eq!(loaded_mission(&engine), Some(&campaign_summary));
+ assert_eq!(
+ loaded_mission_script_bundles(&engine),
+ Some(campaign_bundles.as_slice())
+ );
+ assert_eq!(
+ loaded_mission_script_init_states(&engine),
+ Some(campaign_init_states.as_slice())
+ );
+ assert_eq!(
+ loaded_mission_script_varset_states(&engine),
+ Some(campaign_varsets.as_slice())
+ );
+ assert_eq!(engine.behavior_rng_state, campaign_rng);
+ let after = step_headless(&mut engine, InputSnapshot, 0)
+ .expect("step retained campaign")
+ .snapshot
+ .objects;
+ assert_eq!(before, after);
+ }
+
+ #[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_single_preview_loads_each_formula_table_and_stops_at_handler43() {
+ let root = licensed_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let mut engine = create(EngineServices::new(vfs)).expect("engine");
+ load_mission_static_preview_roots(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/Single.01/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ NonZeroUsize::new(1).expect("non-zero root count"),
+ )
+ .expect("load licensed Single.01 preview");
+
+ let bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
+ assert_eq!(bundles.len(), 2);
+ let first = bundles
+ .iter()
+ .find(|bundle| bundle.clan_index == 0)
+ .expect("scr_pl_1 bundle");
+ assert_eq!(
+ first.base_path.replace('\\', "/"),
+ "MISSIONS/SCRIPTS/scr_pl_1"
+ );
+ assert_eq!(first.formula_table.as_ref().unwrap().rows.len(), 5);
+ assert_eq!(
+ first.init_execution,
+ MissionScriptInitExecution::Executed { event_index: 0 }
+ );
+ let second = bundles
+ .iter()
+ .find(|bundle| bundle.clan_index == 1)
+ .expect("scream bundle");
+ assert_eq!(
+ second.base_path.replace('\\', "/"),
+ "MISSIONS/SCRIPTS/scream"
+ );
+ let formulas = second.formula_table.as_ref().expect("scream FML");
+ assert_eq!(formulas.rows.len(), 149);
+ for (index, expected_bits) in [
+ 0x3ecc_cccd,
+ 0x3f4c_cccd,
+ 0x3f00_0000,
+ 0x3f80_0000,
+ 0x4120_0000,
+ 0x3e4c_cccd,
+ 0x42c8_0000,
+ 0x43c8_0000,
+ 0x4000_0000,
+ ]
+ .into_iter()
+ .enumerate()
+ {
+ assert_eq!(
+ formulas
+ .evaluate_numeric_literal(index as u32)
+ .expect("referenced native literal")
+ .to_bits(),
+ expected_bits
+ );
+ }
+ assert_eq!(
+ second.init_execution,
+ MissionScriptInitExecution::Unsupported {
+ event_index: 0,
+ instruction_index: 10,
+ error: ScriptInitResolveError::UnsupportedSelector {
+ found: fparkan_script::ScriptDispatchSelector::Handler(43),
+ },
+ }
+ );
+
+ let states = loaded_mission_script_init_states(&engine).expect("preview Init writes");
+ assert!(states.iter().any(|state| state.clan_index == 0));
+ assert!(states.iter().all(|state| state.clan_index != 1));
+ let first_varset = loaded_mission_script_varset_states(&engine)
+ .expect("per-clan varsets")
+ .iter()
+ .find(|state| state.clan_index == 0)
+ .expect("scr_pl_1 varset");
+ assert_eq!(
+ &first_varset.values[186..188],
+ &[VarSetDefault::Dword(0), VarSetDefault::Dword(0)]
+ );
+ let declarations = loaded_mission_script_varset(&engine)
+ .expect("shared script varset")
+ .declarations
+ .declarations
+ .iter()
+ .map(|declaration| declaration.default_value)
+ .collect::<Vec<_>>();
+ let second_varset = loaded_mission_script_varset_states(&engine)
+ .expect("per-clan varsets")
+ .iter()
+ .find(|state| state.clan_index == 1)
+ .expect("scream varset");
+ assert_eq!(second_varset.values, declarations);
+ assert!(bundles
+ .iter()
+ .all(|bundle| bundle.behavior_warm_start.is_none()));
}
#[test]
@@ -14131,7 +14601,7 @@ mod tests {
assert!(matches!(
error,
EngineError::ScriptInitUnsupported {
- source: Handler19ResolveError::UnsupportedSelector {
+ source: ScriptInitResolveError::UnsupportedSelector {
found: ScriptDispatchSelector::Handler(18),
},
..
diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs
index e574489..7959ce8 100644
--- a/crates/fparkan-script/src/lib.rs
+++ b/crates/fparkan-script/src/lib.rs
@@ -3,7 +3,7 @@
//!
//! This module preserves the layout proven by the GOG `ai.dll` reader. It
//! deliberately does not implement a general bytecode VM. It contains a
-//! narrow, handler-specific resolver for the confirmed `Init` path.
+//! narrow resolver for confirmed `Init` handlers and numeric FML literals.
use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits};
use std::sync::Arc;
@@ -40,7 +40,7 @@ pub enum VarSetType {
Dword,
}
-/// A lossless-in-meaning numeric default from `varset.var`.
+/// A numeric cell value that preserves the declared `varset.var` type.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum VarSetDefault {
/// IEEE-754 bits parsed from a `float` literal.
@@ -151,33 +151,155 @@ pub struct Handler19DwordWrite {
pub value: u32,
}
-/// Result of resolving the first event whose case-sensitive C-string name is
-/// `Init` (bytes through the first NUL) with the supported
-/// `Handler(19)` action subset.
+/// A typed variable write emitted by the recovered first-`Init` subset.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct ScriptInitWrite {
+ /// Compiled varset declaration index.
+ pub index: u32,
+ /// Value in the original cell's numeric representation.
+ pub value: VarSetDefault,
+}
+
+/// Result of resolving the first case-sensitive `Init` event.
#[derive(Clone, Debug, Eq, PartialEq)]
-pub enum Handler19InitResolution {
- /// No event has the case-sensitive C-string name `Init`; the native lookup
- /// makes no call.
+pub enum ScriptInitResolution {
+ /// No event has the case-sensitive C-string name `Init`.
Missing,
- /// The first `Init\0` event was walked in source order.
+ /// The first `Init` event completed within the recovered subset.
Executed {
- /// Zero-based event table index selected by the native name lookup.
+ /// Zero-based selected event index.
event_index: usize,
- /// Opaque event word retained from the selected record.
+ /// Opaque event word retained from the package.
event_word: u32,
- /// Ordered writes emitted by every supported instruction in the event.
- writes: Vec<Handler19DwordWrite>,
+ /// Ordered, typed writes emitted by the event.
+ writes: Vec<ScriptInitWrite>,
},
- /// The first unsupported instruction stopped this restricted resolver.
- /// Any earlier local writes are discarded because the event did not finish.
+ /// The event reached a known but unsupported form; earlier writes are discarded.
Unsupported {
- /// Zero-based event table index selected by the native name lookup.
+ /// Zero-based selected event index.
event_index: usize,
- /// Zero-based instruction index within that event.
+ /// Zero-based unsupported instruction index.
instruction_index: usize,
- /// Unsupported action shape.
- error: Handler19ResolveError,
+ /// Unsupported instruction or expression form.
+ error: ScriptInitResolveError,
+ },
+}
+
+/// A typed failure or unsupported form in the recovered `Init` subset.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum ScriptInitResolveError {
+ /// The selector is outside the recovered `Handler(19)` and formula assignment paths.
+ UnsupportedSelector {
+ /// Observed selector.
+ found: ScriptDispatchSelector,
+ },
+ /// A `Handler(19)` reference or declaration did not match its recovered contract.
+ Handler19(Handler19ResolveError),
+ /// A formula record has an unsupported control word.
+ UnsupportedFormulaControlWord {
+ /// Raw disk word at index 3.
+ found: u32,
},
+ /// A formula record requests the separate whole-varset-record copy path.
+ UnsupportedFormulaCopySource {
+ /// Raw disk word at index 2.
+ found: u32,
+ },
+ /// The selected script base did not provide an FML table.
+ MissingFormulaTable,
+ /// The FML ordinal is outside the preserved row table.
+ FormulaIndexOutOfBounds {
+ /// Zero-based FML row index.
+ index: u32,
+ /// Number of preserved FML rows.
+ rows: usize,
+ },
+ /// The referenced FML row is not one numeric token.
+ UnsupportedFormulaExpression {
+ /// Zero-based FML row index.
+ index: u32,
+ /// Raw expression bytes from the row.
+ expression: Vec<u8>,
+ },
+ /// A DWORD target cannot represent the native truncating qword conversion.
+ DwordConversionOutOfRange {
+ /// Compiled target index.
+ index: u32,
+ /// Evaluated binary32 payload.
+ value_bits: u32,
+ },
+ /// The formula record target is outside the selected varset.
+ VarSetIndexOutOfBounds {
+ /// Compiled target index.
+ index: u32,
+ /// Available declaration count.
+ declarations: usize,
+ },
+}
+
+impl std::fmt::Display for ScriptInitResolveError {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::UnsupportedSelector { found } => {
+ write!(formatter, "unsupported script selector {found:?} in Init")
+ }
+ Self::Handler19(source) => write!(formatter, "Handler(19): {source}"),
+ Self::UnsupportedFormulaControlWord { found } => write!(
+ formatter,
+ "unsupported formula control word 0x{found:08x}; expected 0xffffffff"
+ ),
+ Self::UnsupportedFormulaCopySource { found } => write!(
+ formatter,
+ "unsupported formula source 0x{found:08x}; whole-varset copy is not modeled"
+ ),
+ Self::MissingFormulaTable => write!(formatter, "formula assignment has no FML table"),
+ Self::FormulaIndexOutOfBounds { index, rows } => write!(
+ formatter,
+ "formula index {index} is outside the FML table with {rows} rows"
+ ),
+ Self::UnsupportedFormulaExpression { index, expression } => write!(
+ formatter,
+ "formula {index} expression {:?} is not one numeric literal",
+ String::from_utf8_lossy(expression)
+ ),
+ Self::DwordConversionOutOfRange { index, value_bits } => write!(
+ formatter,
+ "formula value 0x{value_bits:08x} cannot be converted to DWORD target {index}"
+ ),
+ Self::VarSetIndexOutOfBounds {
+ index,
+ declarations,
+ } => write!(
+ formatter,
+ "formula target {index} is outside {declarations} varset declarations"
+ ),
+ }
+ }
+}
+
+impl std::error::Error for ScriptInitResolveError {}
+
+enum InitInstructionResolution {
+ Executed(Vec<ScriptInitWrite>),
+ Unsupported(ScriptInitResolveError),
+}
+
+/// Rows loaded from one script base's `.fml` file in native table order.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct FormulaTable {
+ /// Accepted `FUNCTION` rows in their original order, including unsupported expressions.
+ pub rows: Vec<FormulaRow>,
+}
+
+/// One source row accepted by the native FML line loader.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct FormulaRow {
+ /// Name token retained from the first comma-delimited field.
+ pub name_raw: Vec<u8>,
+ /// Expression bytes from the second field; interpretation is lazy.
+ pub expression_raw: Vec<u8>,
+ /// Final field bytes, retained without interpreting their meaning.
+ pub trailing_raw: Vec<u8>,
}
/// Inputs already expressed at the original `Handler(19)` setter ABI boundary.
@@ -583,73 +705,6 @@ impl VarSet {
])
}
- /// Resolves only the first event whose case-sensitive C-string name is
- /// `Init` (bytes through the first NUL), and its supported
- /// `Handler(19)` action instructions in source order.
- ///
- /// A missing event is a normal no-write result, matching the native event
- /// lookup. Unsupported selector/control/result-save forms stop at their
- /// first instruction and return no partial writes. Malformed handler
- /// references and declaration types remain errors.
- pub fn resolve_handler19_init(
- &self,
- package: &ScriptPackage,
- input: Handler19InitInput,
- ) -> Result<Handler19InitResolution, Handler19ResolveError> {
- let Some((event_index, event)) = package.events.iter().enumerate().find(|(_, event)| {
- event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])
- }) else {
- return Ok(Handler19InitResolution::Missing);
- };
-
- let mut writes = Vec::new();
- for (instruction_index, instruction) in event.instructions.iter().enumerate() {
- let instruction_writes =
- match self.resolve_handler19_init_instruction(instruction, input) {
- Ok(writes) => writes,
- Err(error @ Handler19ResolveError::UnsupportedSelector { .. })
- | Err(error @ Handler19ResolveError::UnsupportedControlWord { .. })
- | Err(error @ Handler19ResolveError::UnsupportedResultSave { .. }) => {
- return Ok(Handler19InitResolution::Unsupported {
- event_index,
- instruction_index,
- error,
- });
- }
- Err(error) => return Err(error),
- };
- writes.extend(instruction_writes);
- }
-
- Ok(Handler19InitResolution::Executed {
- event_index,
- event_word: event.event_word,
- writes,
- })
- }
-
- fn resolve_handler19_init_instruction(
- &self,
- instruction: &ScriptInstruction,
- input: Handler19InitInput,
- ) -> Result<[Handler19DwordWrite; 3], Handler19ResolveError> {
- let selector = instruction.dispatch_selector();
- if selector != ScriptDispatchSelector::Handler(19) {
- return Err(Handler19ResolveError::UnsupportedSelector { found: selector });
- }
- if instruction.header_words[3] != u32::MAX {
- return Err(Handler19ResolveError::UnsupportedControlWord {
- found: instruction.header_words[3],
- });
- }
- if instruction.header_words[1] != u32::MAX {
- return Err(Handler19ResolveError::UnsupportedResultSave {
- found: instruction.header_words[1],
- });
- }
- self.resolve_handler19(instruction, input)
- }
-
fn resolve_handler19_target(
&self,
instruction: &ScriptInstruction,
@@ -675,6 +730,167 @@ impl VarSet {
}
}
+ /// Resolves the first case-sensitive `Init` event's recovered write subset.
+ ///
+ /// Handler(19) writes use the caller's original setter-boundary words. A
+ /// sentinel selector is accepted only for the proven formula assignment
+ /// with control and copy-source words both equal to `0xffffffff`; the
+ /// referenced row is evaluated only when that instruction is reached.
+ ///
+ /// # Errors
+ ///
+ /// Returns a typed error for malformed Handler(19) references, invalid
+ /// formula or varset indices, and targets outside the selected varset.
+ pub fn resolve_init(
+ &self,
+ package: &ScriptPackage,
+ input: Handler19InitInput,
+ formulas: Option<&FormulaTable>,
+ ) -> Result<ScriptInitResolution, ScriptInitResolveError> {
+ let Some((event_index, event)) = package.events.iter().enumerate().find(|(_, event)| {
+ event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])
+ }) else {
+ return Ok(ScriptInitResolution::Missing);
+ };
+
+ let mut writes = Vec::new();
+ for (instruction_index, instruction) in event.instructions.iter().enumerate() {
+ match self.resolve_init_instruction(instruction, input, formulas)? {
+ InitInstructionResolution::Executed(instruction_writes) => {
+ writes.extend(instruction_writes);
+ }
+ InitInstructionResolution::Unsupported(error) => {
+ return Ok(ScriptInitResolution::Unsupported {
+ event_index,
+ instruction_index,
+ error,
+ });
+ }
+ }
+ }
+
+ Ok(ScriptInitResolution::Executed {
+ event_index,
+ event_word: event.event_word,
+ writes,
+ })
+ }
+
+ fn resolve_init_instruction(
+ &self,
+ instruction: &ScriptInstruction,
+ input: Handler19InitInput,
+ formulas: Option<&FormulaTable>,
+ ) -> Result<InitInstructionResolution, ScriptInitResolveError> {
+ match instruction.dispatch_selector() {
+ ScriptDispatchSelector::Handler(19) => {
+ if instruction.header_words[3] != u32::MAX {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::Handler19(
+ Handler19ResolveError::UnsupportedControlWord {
+ found: instruction.header_words[3],
+ },
+ ),
+ ));
+ }
+ if instruction.header_words[1] != u32::MAX {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::Handler19(
+ Handler19ResolveError::UnsupportedResultSave {
+ found: instruction.header_words[1],
+ },
+ ),
+ ));
+ }
+ match self.resolve_handler19(instruction, input) {
+ Ok(writes) => Ok(InitInstructionResolution::Executed(
+ writes
+ .into_iter()
+ .map(|write| ScriptInitWrite {
+ index: write.index,
+ value: VarSetDefault::Dword(write.value),
+ })
+ .collect(),
+ )),
+ Err(error @ Handler19ResolveError::UnsupportedControlWord { .. })
+ | Err(error @ Handler19ResolveError::UnsupportedResultSave { .. }) => {
+ Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::Handler19(error),
+ ))
+ }
+ Err(error) => Err(ScriptInitResolveError::Handler19(error)),
+ }
+ }
+ ScriptDispatchSelector::Sentinel => {
+ if instruction.header_words[3] != u32::MAX {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedFormulaControlWord {
+ found: instruction.header_words[3],
+ },
+ ));
+ }
+ if instruction.header_words[2] != u32::MAX {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedFormulaCopySource {
+ found: instruction.header_words[2],
+ },
+ ));
+ }
+ let Some(formulas) = formulas else {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::MissingFormulaTable,
+ ));
+ };
+ let formula_index = instruction.header_words[6];
+ let value = match formulas.evaluate_numeric_literal(formula_index) {
+ Ok(value) => value,
+ Err(FormulaLiteralError::IndexOutOfBounds { index, rows }) => {
+ return Err(ScriptInitResolveError::FormulaIndexOutOfBounds {
+ index,
+ rows,
+ });
+ }
+ Err(FormulaLiteralError::UnsupportedExpression { index, expression }) => {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedFormulaExpression {
+ index,
+ expression,
+ },
+ ));
+ }
+ };
+ let target_index = instruction.header_words[1];
+ let Some(target) = self.declarations.get(target_index as usize) else {
+ return Err(ScriptInitResolveError::VarSetIndexOutOfBounds {
+ index: target_index,
+ declarations: self.declarations.len(),
+ });
+ };
+ let value = match target.type_name {
+ VarSetType::Float => VarSetDefault::FloatBits(value.to_bits()),
+ VarSetType::Dword => {
+ let Some(value) = formula_dword_value(value) else {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::DwordConversionOutOfRange {
+ index: target_index,
+ value_bits: value.to_bits(),
+ },
+ ));
+ };
+ VarSetDefault::Dword(value)
+ }
+ };
+ Ok(InitInstructionResolution::Executed(vec![ScriptInitWrite {
+ index: target_index,
+ value,
+ }]))
+ }
+ found => Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedSelector { found },
+ )),
+ }
+ }
+
/// Resolves the fixed-width, corpus-proven operands of GOG `Handler(15)`.
///
/// Static analysis of `ai.dll` at `0x10008054` shows four DWORD slots,
@@ -870,6 +1086,286 @@ impl std::fmt::Display for VarSetError {
impl std::error::Error for VarSetError {}
+/// A failure evaluating one row as a single native numeric literal.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum FormulaLiteralError {
+ /// The referenced ordinal was outside the preserved table.
+ IndexOutOfBounds {
+ /// Zero-based row index.
+ index: u32,
+ /// Number of accepted rows.
+ rows: usize,
+ },
+ /// The row's expression was not one numeric token.
+ UnsupportedExpression {
+ /// Zero-based row index.
+ index: u32,
+ /// Raw expression bytes.
+ expression: Vec<u8>,
+ },
+}
+
+impl std::fmt::Display for FormulaLiteralError {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::IndexOutOfBounds { index, rows } => {
+ write!(formatter, "formula index {index} is outside {rows} rows")
+ }
+ Self::UnsupportedExpression { index, expression } => write!(
+ formatter,
+ "formula {index} expression {:?} is not one numeric literal",
+ String::from_utf8_lossy(expression)
+ ),
+ }
+ }
+}
+
+impl std::error::Error for FormulaLiteralError {}
+
+impl FormulaTable {
+ /// Parses native `FUNCTION` rows without compiling their expressions.
+ ///
+ /// Input is read as text: CRLF becomes LF, Ctrl-Z ends input, and each
+ /// 255-byte read chunk is parsed independently. Accepted rows stay in
+ /// source order so formula references retain their original ordinals.
+ pub fn parse(bytes: &[u8]) -> Self {
+ let mut text = Vec::with_capacity(bytes.len());
+ let mut offset = 0;
+ while let Some(&byte) = bytes.get(offset) {
+ if byte == 0x1a {
+ break;
+ }
+ if byte == b'\r' && bytes.get(offset + 1) == Some(&b'\n') {
+ text.push(b'\n');
+ offset += 2;
+ } else {
+ text.push(byte);
+ offset += 1;
+ }
+ }
+
+ let mut table = Self::default();
+ let mut offset = 0;
+ while offset < text.len() {
+ let limit = (offset + 255).min(text.len());
+ let end = text[offset..limit]
+ .iter()
+ .position(|byte| *byte == b'\n')
+ .map_or(limit, |newline| offset + newline + 1);
+ parse_formula_chunk(&mut table, &text[offset..end]);
+ offset = end;
+ }
+ table
+ }
+
+ /// Evaluates a preserved row only when it is referenced by a formula assignment.
+ ///
+ /// The lexer behavior is bounded to the original number-token rules. The
+ /// converter consumes the longest decimal prefix, converts it to `f64`,
+ /// then rounds to the node's binary32 payload.
+ ///
+ /// # Errors
+ ///
+ /// Returns a typed error for an out-of-range ordinal or an expression
+ /// containing more than one supported numeric token.
+ pub fn evaluate_numeric_literal(&self, index: u32) -> Result<f32, FormulaLiteralError> {
+ let Some(row) = self.rows.get(index as usize) else {
+ return Err(FormulaLiteralError::IndexOutOfBounds {
+ index,
+ rows: self.rows.len(),
+ });
+ };
+ parse_native_numeric_literal(&row.expression_raw).ok_or_else(|| {
+ FormulaLiteralError::UnsupportedExpression {
+ index,
+ expression: row.expression_raw.clone(),
+ }
+ })
+ }
+}
+
+fn parse_formula_chunk(table: &mut FormulaTable, chunk: &[u8]) {
+ let visible_len = chunk
+ .iter()
+ .position(|byte| *byte == 0)
+ .unwrap_or(chunk.len());
+ let visible = &chunk[..visible_len];
+ let first_token = native_first_token(visible);
+ if first_token.iter().take(2).any(|byte| *byte == b'/') {
+ return;
+ }
+ if !visible
+ .windows(b"FUNCTION".len())
+ .any(|part| part == b"FUNCTION")
+ {
+ return;
+ }
+ let Some(open) = visible.iter().position(|byte| *byte == b'(') else {
+ return;
+ };
+ let first_comma = visible.iter().position(|byte| *byte == b',');
+ let second_comma = first_comma.and_then(|first| {
+ visible[first + 1..]
+ .iter()
+ .position(|byte| *byte == b',')
+ .map(|next| first + 1 + next)
+ });
+ let mut buffer = visible.to_vec();
+ buffer.push(0);
+ for byte in &mut buffer[..visible_len] {
+ if *byte == b',' {
+ *byte = 0;
+ }
+ }
+
+ let name_start = open + 1;
+ let expression_start = first_comma.map(|comma| comma + 1);
+ let trailing_start = second_comma.map(|comma| comma + 1);
+ let last_field_start = trailing_start.or(expression_start).unwrap_or(name_start);
+ let last_field_end = c_string_end(&buffer, last_field_start);
+ if let Some(close) = buffer[last_field_start..last_field_end]
+ .iter()
+ .rposition(|byte| *byte == b')')
+ {
+ buffer[last_field_start + close] = 0;
+ }
+
+ let name_field = c_string(&buffer, name_start);
+ if name_field.is_empty() {
+ return;
+ }
+ let expression_field = expression_start.map_or(&[][..], |start| c_string(&buffer, start));
+ let trailing_field = trailing_start.map_or(&[][..], |start| c_string(&buffer, start));
+ let name_token = native_first_token(name_field);
+ let name_raw = if name_token.is_empty() {
+ b"no_name".to_vec()
+ } else {
+ name_token.to_vec()
+ };
+ table.rows.push(FormulaRow {
+ name_raw,
+ expression_raw: expression_field.to_vec(),
+ trailing_raw: trailing_field.to_vec(),
+ });
+}
+
+fn c_string_end(buffer: &[u8], start: usize) -> usize {
+ buffer[start..]
+ .iter()
+ .position(|byte| *byte == 0)
+ .map_or(buffer.len(), |offset| start + offset)
+}
+
+fn c_string(buffer: &[u8], start: usize) -> &[u8] {
+ &buffer[start..c_string_end(buffer, start)]
+}
+
+fn native_first_token(field: &[u8]) -> &[u8] {
+ let start = field
+ .iter()
+ .position(|byte| *byte == 0 || *byte > 32)
+ .unwrap_or(field.len());
+ let end = field[start..]
+ .iter()
+ .position(|byte| *byte == 0 || *byte <= 32)
+ .map_or(field.len(), |offset| start + offset);
+ &field[start..end]
+}
+
+fn parse_native_numeric_literal(expression: &[u8]) -> Option<f32> {
+ let start = expression
+ .iter()
+ .position(|byte| *byte == 0 || *byte > 32)
+ .unwrap_or(expression.len());
+ let expression = &expression[start..];
+ let token_len = native_numeric_token_len(expression)?;
+ if expression[token_len..]
+ .iter()
+ .any(|byte| *byte != 0 && *byte > 32)
+ {
+ return None;
+ }
+ let converted_len = native_decimal_prefix_len(&expression[..token_len])?;
+ let prefix = std::str::from_utf8(&expression[..converted_len]).ok()?;
+ prefix.parse::<f64>().ok().map(|value| value as f32)
+}
+
+fn native_numeric_token_len(expression: &[u8]) -> Option<usize> {
+ if !expression.first().is_some_and(u8::is_ascii_digit) {
+ return None;
+ }
+ let mut index = 0;
+ let mut dot_allowed = true;
+ let mut exponent_sign_allowed = false;
+ while let Some(&byte) = expression.get(index) {
+ if byte.is_ascii_digit() {
+ exponent_sign_allowed = false;
+ index += 1;
+ } else if byte == b'.' && dot_allowed {
+ dot_allowed = false;
+ exponent_sign_allowed = false;
+ index += 1;
+ } else if matches!(byte, b'e' | b'E') {
+ dot_allowed = true;
+ exponent_sign_allowed = true;
+ index += 1;
+ } else if matches!(byte, b'+' | b'-') && exponent_sign_allowed {
+ exponent_sign_allowed = false;
+ index += 1;
+ } else {
+ break;
+ }
+ }
+ Some(index)
+}
+
+fn native_decimal_prefix_len(token: &[u8]) -> Option<usize> {
+ let mut index = 0;
+ while token.get(index).is_some_and(u8::is_ascii_digit) {
+ index += 1;
+ }
+ if token.get(index) == Some(&b'.') {
+ index += 1;
+ while token.get(index).is_some_and(u8::is_ascii_digit) {
+ index += 1;
+ }
+ }
+ if index == 0 {
+ return None;
+ }
+
+ if token
+ .get(index)
+ .is_some_and(|byte| matches!(byte, b'e' | b'E'))
+ {
+ let exponent_start = index;
+ index += 1;
+ if token
+ .get(index)
+ .is_some_and(|byte| matches!(byte, b'+' | b'-'))
+ {
+ index += 1;
+ }
+ let digits_start = index;
+ while token.get(index).is_some_and(u8::is_ascii_digit) {
+ index += 1;
+ }
+ if digits_start == index {
+ index = exponent_start;
+ }
+ }
+ Some(index)
+}
+
+fn formula_dword_value(value: f32) -> Option<u32> {
+ const SIGNED_QWORD_LIMIT: f64 = 9_223_372_036_854_775_808.0;
+ let value = f64::from(value);
+ if !value.is_finite() || value < -SIGNED_QWORD_LIMIT || value >= SIGNED_QWORD_LIMIT {
+ return None;
+ }
+ Some(value.trunc() as i64 as u32)
+}
+
/// A compiled `.scr` package.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ScriptPackage {
@@ -1317,12 +1813,12 @@ fn read_instruction(
#[cfg(test)]
mod tests {
use super::{
- decode, decode_with_limits, parse_varset, Handler15ResolveError, Handler15TargetPayload,
- Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError,
+ decode, decode_with_limits, parse_varset, FormulaTable, Handler15ResolveError,
+ Handler15TargetPayload, Handler19DwordWrite, Handler19InitInput, Handler19ResolveError,
Handler2RecordInput, Handler2RecordScheduler, Handler30ResolveError, Handler8Reset,
- Handler8ResolveError, ScriptDispatchSelector, ScriptEvent, ScriptInstruction,
- ScriptPackage, VarSetDefault, VarSetError, VarSetType, VmHostCallbackCommand,
- GOG_HANDLER_COUNT, INSTRUCTION_WORDS,
+ Handler8ResolveError, ScriptDispatchSelector, ScriptEvent, ScriptInitResolution,
+ ScriptInitResolveError, ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSetDefault,
+ VarSetError, VarSetType, VmHostCallbackCommand, GOG_HANDLER_COUNT, INSTRUCTION_WORDS,
};
use fparkan_binary::{DecodeError, Limits};
@@ -1470,6 +1966,73 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
+ fn formula_table_preserves_native_rows_and_field_defaults() {
+ let table = FormulaTable::parse(
+ b"// a comment\r\nFUNCTION(no_fields)\r\nFUNCTION(name,0)\r\nFUNCTION(name,0,)\r\nFUNCTION( , 2, )\r\nFUNCTION(, 3,)\r\nFUNCTION missing_parenthesis,4,\r\nFUNCTION()\r\n/x FUNCTION(ignored,5,)\r\nx/FUNCTION(ignored,6,)\r\nx,FUNCTION(name,0,)\r\nx,FUNCTION(name)\r\n",
+ );
+
+ assert_eq!(table.rows.len(), 6);
+ assert_eq!(table.rows[0].name_raw, b"no_fields");
+ assert!(table.rows[0].expression_raw.is_empty());
+ assert_eq!(table.rows[1].name_raw, b"name");
+ assert_eq!(table.rows[1].expression_raw, b"0");
+ assert!(table.rows[1].trailing_raw.is_empty());
+ assert_eq!(table.rows[2].expression_raw, b"0");
+ assert!(table.rows[2].trailing_raw.is_empty());
+ assert_eq!(table.rows[3].name_raw, b"no_name");
+ assert_eq!(table.rows[3].expression_raw, b" 2");
+ assert_eq!(table.rows[4].name_raw, b"name");
+ assert_eq!(table.rows[4].expression_raw, b"FUNCTION(name");
+ assert_eq!(table.rows[4].trailing_raw, b"0");
+ assert_eq!(table.rows[5].name_raw, b"name");
+ assert_eq!(table.rows[5].expression_raw, b"FUNCTION(name");
+ assert!(table.rows[5].trailing_raw.is_empty());
+ }
+
+ #[test]
+ fn formula_table_chunks_without_reassembling_and_stops_at_text_eof() {
+ let mut source = vec![b' '; 255];
+ source.extend_from_slice(b"FUNCTION(before, 1, )\r\n");
+ source.extend_from_slice(b"FUNCTION(after, 2, )\r\n");
+ source.push(0x1a);
+ source.extend_from_slice(b"FUNCTION(ignored, 3, )\r\n");
+
+ let table = FormulaTable::parse(&source);
+ assert_eq!(table.rows.len(), 2);
+ assert_eq!(table.rows[0].name_raw, b"before");
+ assert_eq!(table.rows[1].name_raw, b"after");
+ }
+
+ #[test]
+ fn formula_table_uses_only_the_nul_terminated_view_of_each_chunk() {
+ let table = FormulaTable::parse(
+ b"FUNCTION(visible, 1, )\0 FUNCTION(hidden, 2, )\nFUNCTION(next, 3, )\n",
+ );
+ assert_eq!(table.rows.len(), 2);
+ assert_eq!(table.rows[0].name_raw, b"visible");
+ assert_eq!(table.rows[1].name_raw, b"next");
+ }
+
+ #[test]
+ fn formula_literal_evaluation_uses_the_native_numeric_prefix() {
+ let table = FormulaTable::parse(
+ b"FUNCTION(scientific,1e-2,)\nFUNCTION(integer_dot,1.,)\nFUNCTION(exp_prefix,1e2.3,)\nFUNCTION(incomplete,1e+,)\nFUNCTION(unary,-1,)\nFUNCTION(operator,1+2,)\n",
+ );
+
+ for (index, expected) in [(0, 0.01_f32), (1, 1.0), (2, 100.0), (3, 1.0)] {
+ assert_eq!(
+ table
+ .evaluate_numeric_literal(index)
+ .expect("native numeric token"),
+ expected
+ );
+ }
+ assert!(table.evaluate_numeric_literal(4).is_err());
+ assert!(table.evaluate_numeric_literal(5).is_err());
+ assert!(table.evaluate_numeric_literal(6).is_err());
+ }
+
+ #[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",
@@ -1684,7 +2247,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
- fn handler_nineteen_init_uses_first_exact_event_and_keeps_instruction_order() {
+ fn script_init_uses_first_exact_event_and_keeps_instruction_order() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let action = |references: Vec<u32>| ScriptInstruction {
@@ -1715,34 +2278,53 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
let result = varset
- .resolve_handler19_init(
+ .resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
)
.expect("valid first Init event");
assert_eq!(
result,
- Handler19InitResolution::Executed {
+ ScriptInitResolution::Executed {
event_index: 0,
event_word: 0x1234,
writes: vec![
- Handler19DwordWrite { index: 0, value: 7 },
- Handler19DwordWrite { index: 1, value: 8 },
- Handler19DwordWrite { index: 2, value: 9 },
- Handler19DwordWrite { index: 1, value: 7 },
- Handler19DwordWrite { index: 0, value: 8 },
- Handler19DwordWrite { index: 2, value: 9 },
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(7),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::Dword(8),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(9),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::Dword(7),
+ },
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(8),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(9),
+ },
],
}
);
}
#[test]
- fn handler_nineteen_init_missing_exact_name_has_no_writes() {
+ fn script_init_missing_exact_name_has_no_writes() {
let varset = parse_varset(b"").expect("empty varset");
let package = ScriptPackage {
opcode_handler_count: 73,
@@ -1765,20 +2347,21 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
assert_eq!(
- varset.resolve_handler19_init(
+ varset.resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
),
- Ok(Handler19InitResolution::Missing)
+ Ok(ScriptInitResolution::Missing)
);
}
#[test]
- fn handler_nineteen_init_accepts_empty_first_event_as_executed_with_no_writes() {
+ fn script_init_accepts_empty_first_event_as_executed_with_no_writes() {
let varset = parse_varset(b"").expect("empty varset");
let package = ScriptPackage {
opcode_handler_count: 73,
@@ -1804,15 +2387,16 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
assert_eq!(
- varset.resolve_handler19_init(
+ varset.resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
),
- Ok(Handler19InitResolution::Executed {
+ Ok(ScriptInitResolution::Executed {
event_index: 0,
event_word: 0x1234,
writes: Vec::new(),
@@ -1821,7 +2405,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
- fn handler_nineteen_init_reports_unsupported_selector_and_discards_prior_writes() {
+ fn script_init_reports_unsupported_selector_and_discards_prior_writes() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let package = ScriptPackage {
@@ -1850,18 +2434,19 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
assert_eq!(
- varset.resolve_handler19_init(
+ varset.resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
),
- Ok(Handler19InitResolution::Unsupported {
+ Ok(ScriptInitResolution::Unsupported {
event_index: 0,
instruction_index: 1,
- error: Handler19ResolveError::UnsupportedSelector {
+ error: ScriptInitResolveError::UnsupportedSelector {
found: super::ScriptDispatchSelector::Handler(18),
},
})
@@ -1869,7 +2454,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
- fn handler_nineteen_init_stops_at_unsupported_selector_before_any_write() {
+ fn script_init_stops_at_unsupported_selector_before_any_write() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let package = ScriptPackage {
@@ -1894,18 +2479,19 @@ STRING( 8, ignored, ignored, ignored)\r\n";
};
assert_eq!(
- varset.resolve_handler19_init(
+ varset.resolve_init(
&package,
Handler19InitInput {
first_x87_word: 7,
second_x87_word: 8,
third_word: 9,
},
+ None,
),
- Ok(Handler19InitResolution::Unsupported {
+ Ok(ScriptInitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
- error: Handler19ResolveError::UnsupportedSelector {
+ error: ScriptInitResolveError::UnsupportedSelector {
found: super::ScriptDispatchSelector::Handler(18),
},
})
@@ -1913,7 +2499,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
- fn handler_nineteen_init_uses_disk_control_and_result_fields() {
+ fn script_init_uses_handler_nineteen_control_and_result_fields() {
let varset = parse_varset(b"VAR( DWORD, x, 0)\nVAR( DWORD, y, 0)\nVAR( DWORD, id, 0)\n")
.expect("DWORD targets");
let input = Handler19InitInput {
@@ -1936,7 +2522,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
trailing_bytes: Vec::new(),
raw: std::sync::Arc::from(&b""[..]),
};
- varset.resolve_handler19_init(&package, input)
+ varset.resolve_init(&package, input, None)
};
let actual = (
@@ -1947,23 +2533,36 @@ STRING( 8, ignored, ignored, ignored)\r\n";
assert_eq!(
actual,
(
- Ok(Handler19InitResolution::Unsupported {
+ Ok(ScriptInitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
- error: Handler19ResolveError::UnsupportedControlWord { found: 2 },
+ error: ScriptInitResolveError::Handler19(
+ Handler19ResolveError::UnsupportedControlWord { found: 2 },
+ ),
}),
- Ok(Handler19InitResolution::Unsupported {
+ Ok(ScriptInitResolution::Unsupported {
event_index: 0,
instruction_index: 0,
- error: Handler19ResolveError::UnsupportedResultSave { found: 0x1234 },
+ error: ScriptInitResolveError::Handler19(
+ Handler19ResolveError::UnsupportedResultSave { found: 0x1234 },
+ ),
}),
- Ok(Handler19InitResolution::Executed {
+ Ok(ScriptInitResolution::Executed {
event_index: 0,
event_word: 0,
writes: vec![
- Handler19DwordWrite { index: 0, value: 7 },
- Handler19DwordWrite { index: 1, value: 8 },
- Handler19DwordWrite { index: 2, value: 9 },
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(7),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::Dword(8),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(9),
+ },
],
}),
)
@@ -1971,6 +2570,171 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
+ fn script_init_assigns_numeric_literals_using_the_target_type() {
+ let varset = parse_varset(b"VAR(DWORD, integer, 0)\nVAR(float, scalar, 0)\n")
+ .expect("numeric declarations");
+ let package = ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![
+ ScriptInstruction {
+ header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 0xdead_beef, 0, 0],
+ references: Vec::new(),
+ },
+ ScriptInstruction {
+ header_words: [u32::MAX, 1, u32::MAX, u32::MAX, 0xdead_beef, 0, 1],
+ references: Vec::new(),
+ },
+ ],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+ let formulas = FormulaTable::parse(
+ b"FUNCTION(integer, 12.75, )\nFUNCTION(scalar, 0.8, )\nFUNCTION(unused, target + 2, )\n",
+ );
+
+ assert_eq!(
+ varset.resolve_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ ),
+ Ok(ScriptInitResolution::Executed {
+ event_index: 0,
+ event_word: 0,
+ writes: vec![
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(12),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::FloatBits(0x3f4c_cccd),
+ },
+ ],
+ })
+ );
+ }
+
+ #[test]
+ fn script_init_keeps_unsupported_formula_expressions_lazy_and_transactional() {
+ let varset = parse_varset(b"VAR(DWORD, integer, 0)\n").expect("numeric declaration");
+ let formulas =
+ FormulaTable::parse(b"FUNCTION(unsupported, target + 2, )\nFUNCTION(literal, 7.5, )\n");
+ let action = |formula_index| ScriptInstruction {
+ header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, formula_index],
+ references: Vec::new(),
+ };
+ let package = |formula_index| ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![action(formula_index)],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+ let input = Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ };
+
+ assert_eq!(
+ varset.resolve_init(&package(1), input, Some(&formulas)),
+ Ok(ScriptInitResolution::Executed {
+ event_index: 0,
+ event_word: 0,
+ writes: vec![ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(7),
+ }],
+ })
+ );
+ assert_eq!(
+ varset.resolve_init(&package(0), input, Some(&formulas)),
+ Ok(ScriptInitResolution::Unsupported {
+ event_index: 0,
+ instruction_index: 0,
+ error: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 0,
+ expression: b" target + 2".to_vec(),
+ },
+ })
+ );
+
+ let mut transactional = package(0);
+ transactional.events[0].instructions.insert(0, action(1));
+ assert_eq!(
+ varset.resolve_init(&transactional, input, Some(&formulas)),
+ Ok(ScriptInitResolution::Unsupported {
+ event_index: 0,
+ instruction_index: 1,
+ error: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 0,
+ expression: b" target + 2".to_vec(),
+ },
+ })
+ );
+ }
+
+ #[test]
+ fn script_init_keeps_missing_table_and_bad_indices_typed() {
+ let varset = parse_varset(b"VAR(DWORD, integer, 0)\n").expect("numeric declaration");
+ let instruction = |target, formula_index| ScriptInstruction {
+ header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, formula_index],
+ references: Vec::new(),
+ };
+ let package = |target, formula_index| ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions: vec![instruction(target, formula_index)],
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ };
+ let input = Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ };
+ let formulas = FormulaTable::parse(b"FUNCTION(literal, 1, )\n");
+
+ assert_eq!(
+ varset.resolve_init(&package(0, 0), input, None),
+ Ok(ScriptInitResolution::Unsupported {
+ event_index: 0,
+ instruction_index: 0,
+ error: ScriptInitResolveError::MissingFormulaTable,
+ })
+ );
+ assert_eq!(
+ varset.resolve_init(&package(1, 0), input, Some(&formulas)),
+ Err(ScriptInitResolveError::VarSetIndexOutOfBounds {
+ index: 1,
+ declarations: 1,
+ })
+ );
+ assert_eq!(
+ varset.resolve_init(&package(0, 1), input, Some(&formulas)),
+ Err(ScriptInitResolveError::FormulaIndexOutOfBounds { index: 1, rows: 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",