feat(script): execute numeric FML assignments during mission Init
Docs Deploy / Build and Deploy MkDocs (push) Successful in 36s

This commit is contained in:
Valentin Popov committed 2026-10-31 03:00:05 +04:00
1 parent 7f8a37d22a
commit dbb59b00d1
5 files changed
+1586 -232

No files matched your search

+543 -73
View File
@@ -22,8 +22,9 @@ use fparkan_prototype::{
}; };
use fparkan_resource::{resource_name, CachedResourceRepository, ResourceKey, ResourceRepository}; use fparkan_resource::{resource_name, CachedResourceRepository, ResourceKey, ResourceRepository};
use fparkan_script::{ use fparkan_script::{
Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError, FormulaTable, Handler19InitInput, ScriptInitResolution, ScriptInitResolveError,
ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand, ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSet, VarSetDefault,
VmHostCallbackCommand,
}; };
use fparkan_terrain::SurfaceQuery; use fparkan_terrain::SurfaceQuery;
use fparkan_vfs::{Vfs, VfsError}; use fparkan_vfs::{Vfs, VfsError};
@@ -657,9 +658,11 @@ pub struct MissionScriptBundle {
pub base_path: String, pub base_path: String,
/// Resolved compiled `.scr` path. /// Resolved compiled `.scr` path.
pub script_path: String, pub script_path: String,
/// Losslessly decoded compiled package; only proven `Handler(19)` Init /// Losslessly decoded compiled package; only the recovered first-`Init`
/// writes are resolved and retained by runtime. /// subset is resolved and retained by runtime.
pub package: ScriptPackage, 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. /// Outcome of the restricted first-`Init` resolution for this bundle.
pub init_execution: MissionScriptInitExecution, pub init_execution: MissionScriptInitExecution,
/// Recovered per-AI Behavior scheduler and total state. /// Recovered per-AI Behavior scheduler and total state.
@@ -725,11 +728,12 @@ fn schedule_behavior_distributor_tick(
Some(behavior_elapsed_seconds(elapsed_ms)) 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 /// GOG `ai.dll` first tries `<bundle-base>.var`, then falls back to the shared
/// `MISSIONS/SCRIPTS/varset.var`. This stores the successfully selected source /// `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)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionScriptVarSet { pub struct MissionScriptVarSet {
/// Resolved `.var` path selected by the original fallback order. /// Resolved `.var` path selected by the original fallback order.
@@ -740,11 +744,7 @@ pub struct MissionScriptVarSet {
pub declarations: VarSet, pub declarations: VarSet,
} }
/// One resolved, corpus-proven `Init` result for a mission clan. /// One resolved subset of the first `Init` event 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.
#[derive(Clone, Debug, Eq, PartialEq)] #[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionScriptInitState { pub struct MissionScriptInitState {
/// TMA clan index that owns the `SuperAI` instance and varset. /// TMA clan index that owns the `SuperAI` instance and varset.
@@ -753,8 +753,8 @@ pub struct MissionScriptInitState {
pub event_index: usize, pub event_index: usize,
/// Raw event word preserved from the compiled `Init` event. /// Raw event word preserved from the compiled `Init` event.
pub event_word: u32, pub event_word: u32,
/// Exact three DWORD writes made by each proven `Handler(19)` instruction. /// Ordered numeric writes made by the supported `Init` instructions.
pub writes: Vec<Handler19DwordWrite>, pub writes: Vec<ScriptInitWrite>,
} }
/// Execution status of the first event whose case-sensitive C-string name is `Init`. /// Execution status of the first event whose case-sensitive C-string name is `Init`.
@@ -768,14 +768,14 @@ pub enum MissionScriptInitExecution {
event_index: usize, event_index: usize,
}, },
/// A preview retained the decoded package but stopped before the first /// 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 { Unsupported {
/// Zero-based event table index selected by native lookup. /// Zero-based event table index selected by native lookup.
event_index: usize, event_index: usize,
/// Zero-based instruction index where restricted resolution stopped. /// Zero-based instruction index where restricted resolution stopped.
instruction_index: usize, instruction_index: usize,
/// Typed selector, control, or result-save shape that is not modeled. /// Typed selector, operand, or expression shape that is not modeled.
error: Handler19ResolveError, error: ScriptInitResolveError,
}, },
} }
@@ -918,7 +918,7 @@ pub struct LoadedMission {
pub script_event_count: usize, pub script_event_count: usize,
/// Numeric script defaults loaded through the original bundle/fallback rule. /// Numeric script defaults loaded through the original bundle/fallback rule.
pub script_varset_declaration_count: usize, 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, pub script_init_state_count: usize,
/// Instantiated per-clan varsets whose Init writes have been applied. /// Instantiated per-clan varsets whose Init writes have been applied.
pub script_varset_state_count: usize, pub script_varset_state_count: usize,
@@ -997,7 +997,7 @@ pub enum EngineError {
/// Parse diagnostic. /// Parse diagnostic.
message: String, message: String,
}, },
/// A recovered `Handler(19)` Init input or target was not representable. /// A recovered `Init` input or write target was not representable.
ScriptInit { ScriptInit {
/// Owning TMA clan index. /// Owning TMA clan index.
clan_index: usize, clan_index: usize,
@@ -1015,7 +1015,7 @@ pub enum EngineError {
/// Zero-based instruction index within that event. /// Zero-based instruction index within that event.
instruction_index: usize, instruction_index: usize,
/// Typed unsupported instruction shape. /// Typed unsupported instruction shape.
source: Handler19ResolveError, source: ScriptInitResolveError,
}, },
/// A recovered script handler could not resolve against runtime state. /// A recovered script handler could not resolve against runtime state.
ScriptRuntime { ScriptRuntime {
@@ -1831,10 +1831,11 @@ pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVar
.and_then(|state| state.script_varset.as_ref()) .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 /// The list has one entry for each clan whose first `Init` event contains a
/// `Handler(19)` instructions. It is not a general script event executor. /// supported `Handler(19)` instruction or numeric FML literal assignment. It
/// is not a general script event executor.
#[must_use] #[must_use]
pub fn loaded_mission_script_init_states(engine: &Engine) -> Option<&[MissionScriptInitState]> { pub fn loaded_mission_script_init_states(engine: &Engine) -> Option<&[MissionScriptInitState]> {
engine 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( fn load_mission_scripts(
vfs: &Arc<dyn Vfs>, vfs: &Arc<dyn Vfs>,
mission: &MissionDocument, mission: &MissionDocument,
@@ -6356,6 +6388,7 @@ fn load_mission_scripts(
path: normalized.as_str().to_string(), path: normalized.as_str().to_string(),
message: source.to_string(), message: source.to_string(),
})?; })?;
let formula_table = load_script_formula_table(vfs, &base_path)?;
if first_base_path.is_none() { if first_base_path.is_none() {
first_base_path = Some(base_path.clone()); first_base_path = Some(base_path.clone());
} }
@@ -6364,6 +6397,7 @@ fn load_mission_scripts(
base_path, base_path,
script_path: normalized.as_str().to_string(), script_path: normalized.as_str().to_string(),
package, package,
formula_table,
init_execution: MissionScriptInitExecution::Missing, init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None, behavior_warm_start: None,
}); });
@@ -6375,7 +6409,7 @@ fn load_mission_scripts(
let (init_states, varset_states) = if asset_scope == MissionAssetScope::Full { let (init_states, varset_states) = if asset_scope == MissionAssetScope::Full {
(Vec::new(), Vec::new()) (Vec::new(), Vec::new())
} else if let Some(varset) = &varset { } else if let Some(varset) = &varset {
let init_states = resolve_handler19_init_states( let init_states = resolve_init_states(
&mission &mission
.clans .clans
.iter() .iter()
@@ -6420,7 +6454,7 @@ fn start_mission_scripts(
)); ));
if let Some(varset) = scripts.varset.as_ref() { if let Some(varset) = scripts.varset.as_ref() {
init_states.extend(resolve_handler19_init_states( init_states.extend(resolve_init_states(
&clan_anchors, &clan_anchors,
std::slice::from_mut(bundle), std::slice::from_mut(bundle),
varset, varset,
@@ -6462,12 +6496,12 @@ fn materialize_script_varset_states(
return Err(EngineError::ScriptInit { return Err(EngineError::ScriptInit {
clan_index: bundle.clan_index, clan_index: bundle.clan_index,
message: format!( message: format!(
"Handler(19) write index {} is outside the instantiated varset", "Init write index {} is outside the instantiated varset",
write.index write.index
), ),
}); });
}; };
*value = VarSetDefault::Dword(write.value); *value = write.value;
} }
} }
Ok(MissionScriptVarSetState { Ok(MissionScriptVarSetState {
@@ -6478,7 +6512,7 @@ fn materialize_script_varset_states(
.collect() .collect()
} }
fn resolve_handler19_init_states( fn resolve_init_states(
clan_anchors: &[[f32; 2]], clan_anchors: &[[f32; 2]],
bundles: &mut [MissionScriptBundle], bundles: &mut [MissionScriptBundle],
varset: &MissionScriptVarSet, varset: &MissionScriptVarSet,
@@ -6511,15 +6545,15 @@ fn resolve_handler19_init_states(
}; };
match varset match varset
.declarations .declarations
.resolve_handler19_init(&bundle.package, input) .resolve_init(&bundle.package, input, bundle.formula_table.as_ref())
.map_err(|source| EngineError::ScriptInit { .map_err(|source| EngineError::ScriptInit {
clan_index: bundle.clan_index, clan_index: bundle.clan_index,
message: source.to_string(), message: source.to_string(),
})? { })? {
Handler19InitResolution::Missing => { ScriptInitResolution::Missing => {
bundle.init_execution = MissionScriptInitExecution::Missing; bundle.init_execution = MissionScriptInitExecution::Missing;
} }
Handler19InitResolution::Executed { ScriptInitResolution::Executed {
event_index, event_index,
event_word, event_word,
writes, writes,
@@ -6534,7 +6568,7 @@ fn resolve_handler19_init_states(
}); });
} }
} }
Handler19InitResolution::Unsupported { ScriptInitResolution::Unsupported {
event_index, event_index,
instruction_index, instruction_index,
error, error,
@@ -6545,7 +6579,7 @@ fn resolve_handler19_init_states(
error, error,
}; };
} }
Handler19InitResolution::Unsupported { ScriptInitResolution::Unsupported {
event_index, event_index,
instruction_index, instruction_index,
error, error,
@@ -9626,6 +9660,7 @@ mod tests {
trailing_bytes: Vec::new(), trailing_bytes: Vec::new(),
raw: Arc::from(&b""[..]), raw: Arc::from(&b""[..]),
}, },
formula_table: None,
init_execution: MissionScriptInitExecution::Missing, init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None, behavior_warm_start: None,
}; };
@@ -9642,7 +9677,7 @@ mod tests {
assert!(matches!( assert!(matches!(
error, error,
EngineError::ScriptInitUnsupported { EngineError::ScriptInitUnsupported {
source: Handler19ResolveError::UnsupportedSelector { source: ScriptInitResolveError::UnsupportedSelector {
found: fparkan_script::ScriptDispatchSelector::Handler(18), found: fparkan_script::ScriptDispatchSelector::Handler(18),
}, },
.. ..
@@ -12212,6 +12247,204 @@ mod tests {
assert_eq!(rows[stage_key as usize].initial_amount_bits, stage_initial); assert_eq!(rows[stage_key as usize].initial_amount_bits, stage_initial);
} }
#[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] #[test]
fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() { fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() {
let mut personal_files = MemoryVfs::default(); let mut personal_files = MemoryVfs::default();
@@ -12261,6 +12494,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] #[test]
fn handler_nineteen_init_executes_from_clan_anchor_for_each_bundle() { fn handler_nineteen_init_executes_from_clan_anchor_for_each_bundle() {
let declarations = fparkan_script::parse_varset( let declarations = fparkan_script::parse_varset(
@@ -12290,11 +12544,12 @@ mod tests {
trailing_bytes: Vec::new(), trailing_bytes: Vec::new(),
raw: Arc::from(&b""[..]), raw: Arc::from(&b""[..]),
}, },
formula_table: None,
init_execution: MissionScriptInitExecution::Missing, init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None, behavior_warm_start: None,
}]; }];
let states = resolve_handler19_init_states( let states = resolve_init_states(
&[[500.0, 752.0], [728.0, 449.0]], &[[500.0, 752.0], [728.0, 449.0]],
&mut bundles, &mut bundles,
&varset, &varset,
@@ -12308,15 +12563,18 @@ mod tests {
event_index: 0, event_index: 0,
event_word: 0x1234_5678, event_word: 0x1234_5678,
writes: vec![ writes: vec![
Handler19DwordWrite { ScriptInitWrite {
index: 0, index: 0,
value: 728 value: VarSetDefault::Dword(728),
}, },
Handler19DwordWrite { ScriptInitWrite {
index: 1, 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(), base_path: "MISSIONS/SCRIPTS/default".to_string(),
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(), script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
package: package.clone(), package: package.clone(),
formula_table: None,
init_execution: MissionScriptInitExecution::Missing, init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None, behavior_warm_start: None,
}; };
let anchors = [[500.0, 752.0]]; let anchors = [[500.0, 752.0]];
let mut preview_bundles = vec![bundle()]; let mut preview_bundles = vec![bundle()];
let preview_states = resolve_handler19_init_states( let preview_states = resolve_init_states(
&anchors, &anchors,
&mut preview_bundles, &mut preview_bundles,
&varset, &varset,
@@ -12405,7 +12664,7 @@ mod tests {
MissionScriptInitExecution::Unsupported { MissionScriptInitExecution::Unsupported {
event_index: 0, event_index: 0,
instruction_index: 1, instruction_index: 1,
error: Handler19ResolveError::UnsupportedSelector { error: ScriptInitResolveError::UnsupportedSelector {
found: ScriptDispatchSelector::Handler(18), found: ScriptDispatchSelector::Handler(18),
}, },
} }
@@ -12426,7 +12685,7 @@ mod tests {
let mut full_bundles = vec![bundle()]; let mut full_bundles = vec![bundle()];
assert!(matches!( assert!(matches!(
resolve_handler19_init_states( resolve_init_states(
&anchors, &anchors,
&mut full_bundles, &mut full_bundles,
&varset, &varset,
@@ -12436,7 +12695,7 @@ mod tests {
clan_index: 0, clan_index: 0,
event_index: 0, event_index: 0,
instruction_index: 1, instruction_index: 1,
source: Handler19ResolveError::UnsupportedSelector { source: ScriptInitResolveError::UnsupportedSelector {
found: ScriptDispatchSelector::Handler(18), found: ScriptDispatchSelector::Handler(18),
}, },
.. ..
@@ -12452,7 +12711,7 @@ mod tests {
..bundle() ..bundle()
}]; }];
assert!(matches!( assert!(matches!(
resolve_handler19_init_states( resolve_init_states(
&anchors, &anchors,
&mut malformed_bundle, &mut malformed_bundle,
&varset, &varset,
@@ -13731,16 +13990,17 @@ mod tests {
#[test] #[test]
#[ignore = "requires licensed corpus"] #[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 root = licensed_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); 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( let loaded = load_mission_static_preview_roots(
&mut engine, &mut engine,
MissionRequest { campaign_request(),
key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
mode: MissionMode::SingleMission,
},
NonZeroUsize::new(1).expect("non-zero root count"), NonZeroUsize::new(1).expect("non-zero root count"),
) )
.expect("load licensed campaign preview"); .expect("load licensed campaign preview");
@@ -13759,42 +14019,252 @@ mod tests {
.iter() .iter()
.all(|bundle| bundle.behavior_warm_start.is_none())); .all(|bundle| bundle.behavior_warm_start.is_none()));
assert_eq!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE); assert_eq!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE);
let unsupported = bundles let bundle = bundles
.iter() .iter()
.find(|bundle| { .find(|bundle| bundle.clan_index == 0)
matches!( .expect("first campaign script bundle");
&bundle.init_execution,
MissionScriptInitExecution::Unsupported { .. }
)
})
.expect("campaign package with an unsupported Init instruction");
assert_eq!( assert_eq!(
&unsupported.init_execution, bundle.base_path.replace('\\', "/"),
&MissionScriptInitExecution::Unsupported { "MISSIONS/SCRIPTS/tut1_pl2"
event_index: 0, );
instruction_index: 1, assert_eq!(bundle.formula_table.as_ref().unwrap().rows.len(), 26);
error: Handler19ResolveError::UnsupportedSelector { assert_eq!(
found: fparkan_script::ScriptDispatchSelector::Sentinel, 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"); 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) let varset = loaded_mission_script_varset_states(&engine)
.expect("per-clan varsets") .expect("per-clan varsets")
.iter() .iter()
.find(|state| state.clan_index == clan_index) .find(|state| state.clan_index == 0)
.expect("unsupported clan varset"); .expect("first clan varset");
assert_eq!(
&varset.values[188..192],
&[
VarSetDefault::Dword(0),
VarSetDefault::Dword(0),
VarSetDefault::Dword(0),
VarSetDefault::Dword(0),
]
);
assert_eq!( assert_eq!(
&varset.values[224..227], &varset.values[224..227],
&[ &[
VarSetDefault::Dword(950), VarSetDefault::Dword(549),
VarSetDefault::Dword(1000), VarSetDefault::Dword(630),
VarSetDefault::Dword(0), VarSetDefault::Dword(0),
] ]
); );
assert!(loaded.script_bundle_count >= 1); 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] #[test]
@@ -14131,7 +14601,7 @@ mod tests {
assert!(matches!( assert!(matches!(
error, error,
EngineError::ScriptInitUnsupported { EngineError::ScriptInitUnsupported {
source: Handler19ResolveError::UnsupportedSelector { source: ScriptInitResolveError::UnsupportedSelector {
found: ScriptDispatchSelector::Handler(18), found: ScriptDispatchSelector::Handler(18),
}, },
.. ..
File diff suppressed because it is too large. Load diff
+43 -16
View File
@@ -130,27 +130,54 @@ Iron3D и указатели `GetSuperAI` из `ai.dll` (таблица RVA `0x5
tie-векторы проверяют, как сам `FISTP` реагирует на каждый RC, но не измеряют tie-векторы проверяют, как сам `FISTP` реагирует на каждый RC, но не измеряют
control word процесса. control word процесса.
Отдельно проверены загрузка лицензированного AutoDemo в Rust и preview Лицензированный AutoDemo по-прежнему загружается с raw TMA anchor-ами
лицензированной кампании. AutoDemo даёт raw TMA anchor-ы `[(500.2857, 751.8217), (728.0, 449.0)]` и записями `[(500, 752, 0),
`[(500.2857, 751.8217), (728.0, 449.0)]` и записи `[(500, 752, 0), (728, 449, 1)]`. Rust теперь также читает optional `.fml` sibling для каждого
(728, 449, 1)]`; preview кампании сохраняет package и статус `Unsupported`, script base и поддерживает numeric literal assignments в первом `Init`.
не применяя частичные записи. Это подтверждает загрузчик и узкий Init resolver Отсутствующий `.fml` не мешает пакету с одними `Handler(19)`; другие ошибки
Rust. чтения распространяются как VFS errors.
Нативный конструктор показывает предупреждение при координате выше `10000`, но Нативный конструктор показывает предупреждение при координате выше `10000`, но
продолжает работу. Rust моделирует числовую запись, а не это UI-предупреждение; продолжает работу. Rust моделирует числовую запись, а не это UI-предупреждение;
на первом `FISTP qword` он явно завершает загрузку ошибкой для NaN, infinity и на первом `FISTP qword` он явно завершает загрузку ошибкой для NaN, infinity и
значений вне signed-qword диапазона. В первом событии `Init` Rust поддерживает значений вне signed-qword диапазона. В первом событии `Init` Rust поддерживает
только action-форму `Handler(19)`: в семи disk header words control находится в два доказанных действия: `Handler(19)` и formula assignment selector `-1` при
`header_words[3]`, а result-save reference — в `header_words[1]`; для поддержанной source `-1`, нулевой глубине условий и корректных индексах. У `Handler(19)`
формы оба равны `0xffffffff`. Это подтверждается перестановкой записи в control находится в `header_words[3]`, а `header_words[1]` — result-save
`ai.dll!FUN_10011b20` (RVA `0x11b20`) и чтениями в исполнителе reference; оба равны `0xffffffff` в поддержанной форме. У formula assignment
`FUN_10012020` (RVA `0x12020`) той же GOG-сборки. `Handler(19)` (`FUN_1000aa38`, `header_words[1]` — индекс целевой varset ячейки, `header_words[2]` — source,
RVA `0xaa38`) использует references `0..2`; `header_words[2]` его result-save а индекс formula row занимает `header_words[6]`. Маппинг подтверждён
guard-ом не проверяется. Неизвестная инструкция даёт `Unsupported` в preview, `ai.dll!FUN_10011b20` (RVA `0x11b20`) и исполнителем `FUN_10012020` (RVA
не применяя накопленные частичные записи; при полной загрузке это ошибка. Это `0x12020`), SHA-256 `c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`.
ограничение Rust исполнителя, не правило оригинального dispatcher-а. Ошибочные `Handler(19)` (`FUN_1000aa38`, RVA `0xaa38`) использует references `0..2`;
ссылки и типы остаются ошибками в обоих режимах. `header_words[2]` его result-save guard-ом не проверяется.
Formula table загружается из `<bundle-base>.fml`; native `FUN_100123b0`
(RVA `0x123b0`) проверяет ordinal, compiler `FUN_100152a0` (RVA `0x152a0`)
строит numeric literal node, а evaluator `FUN_10015b30` (RVA `0x15b30`)
возвращает его raw `f32`. Target type `3` сохраняет эти bits; type `5` проходит
через `FUN_10012c00` (RVA `0x12c00`) и `__ftol` (RVA `0x1df70`) к raw DWORD
с усечением к нулю. Изолированный x86 oracle на оригинальном `ai.dll` и
настоящих ресурсах подтвердил таблицы `tut1_pl2`/`scr_pl_1`/`scream` с
26/5/149 строками и 15 присваиваний: кампанийные `df0..df3` и `Single.01`
`dT8/dT9` получают `0`; `scream` задаёт `fDifficulty = 0.8f` и остальные
проверенные DWORD `0, 0, 1, 10, 0, 100, 400, 2`.
Граница Rust остаётся узкой: ссылка должна вести на один digit-starting numeric
token и типизированную `float`/`DWORD` ячейку. Неизвестные expression nodes,
переменные, unary operators, составные выражения и source-copy ветвь
`source != 0xffffffff` остаются явными ошибками; native source-copy переносит
всю varset record, не только числовое значение. В preview package сохраняется,
но `Unsupported` не применяет частичные writes; полная загрузка отклоняет весь
новый мир и сохраняет предыдущие world/RNG. Это политика Rust, не правило
оригинального dispatcher-а.
Лицензированный test загружает одну первую кампанийную миссию
`CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma` после literal writes; это не проход
всей цепочки кампании. Обычная `Single.01` доходит до следующего blocker-а:
`Handler(43)` в clan 1, `Init` instruction 10. Rust не перескакивает его;
preview оставляет varset этого клана на defaults, а полный load завершает ошибкой
с rollback. Это ещё не загрузка/запуск игры и не общий dispatcher.
Для GOG-сборки также найден статический host call path в `Init`. В Для GOG-сборки также найден статический host call path в `Init`. В
`iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи `iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи
+90 -14
View File
@@ -22,13 +22,15 @@ symbol/formula oracle; `varset.var` задаёт `VAR(...)`/`STRING(...)` defaul
`fparkan-script::parse_varset` уже читает подтверждённые numeric `fparkan-script::parse_varset` уже читает подтверждённые numeric
`VAR(float|DWORD, name, default)` declarations byte-safe (comments остаются `VAR(float|DWORD, name, default)` declarations byte-safe (comments остаются
opaque, поэтому legacy non-UTF-8 text не ломает загрузку); `STRING(...)` и opaque, поэтому legacy non-UTF-8 text не ломает загрузку); `STRING(...)` и
`FUNCTION(...)` пока сохранены за границей этого numeric contract; general `FUNCTION(...)` evaluation остаётся вне numeric contract, но принятые
строки сохраняются для доказанных literal assignments первого `Init`;
GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration: GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration:
31 `float` и 200 `DWORD` (от `f0` до `fY`); 31 `float` и 200 `DWORD` (от `f0` до `fY`);
loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при
`not found` откатывается к этому shared file. Runtime повторяет данный порядок `not found` откатывается к этому shared file. Runtime повторяет данный порядок
транзакционно и публикует selected `MissionScriptVarSet` с путём/provenance, но транзакционно, создаёт typed varset cells для кланов и применяет поддержанные
ещё не исполняет declarations как VM state; записи первого `Init`. Это начальное состояние сценария, а не исполнение общего
VM state;
`.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь `.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь
consumer-inferred. consumer-inferred.
@@ -285,9 +287,10 @@ TMA properties остаются four raw `u32` words плюс имя, пока c
Research/economy работают в simulation: `LoadResearch` и Research/economy работают в simulation: `LoadResearch` и
`CalcFullResearchCost` доказывают данные и вычислимую стоимость, но не полный `CalcFullResearchCost` доказывают данные и вычислимую стоимость, но не полный
layout prerequisites/modifiers/unlocks. Formula evaluator требует strict layout prerequisites/modifiers/unlocks. General formula evaluator требует
grammar/version, typed operands, deterministic numeric policy, bounded stack и versioned grammar, typed operands, deterministic numeric policy, bounded stack
явных errors; x87-compatible rounding нужен там, где оно выбирает ветку. и явных errors; x87-compatible rounding нужен там, где оно выбирает ветку.
Описанный ниже literal-путь первого `Init` не закрывает общий evaluator.
### Первый фрагмент dispatcher-а: `Init` и Handler(19) ### Первый фрагмент dispatcher-а: `Init` и Handler(19)
@@ -362,10 +365,12 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D
показывает исходное предупреждение `MessageBoxA`; оно находится за пределами показывает исходное предупреждение `MessageBoxA`; оно находится за пределами
этого числового resolver-а. этого числового resolver-а.
Ограниченный Rust-исполнитель `VarSet::resolve_handler19_init` ищет только Ограниченный Rust-исполнитель `VarSet::resolve_init` ищет первое событие
первое событие `Init`, проходит его инструкции по порядку и возвращает `Init` и проходит его инструкции по порядку. Он возвращает записи для
упорядоченные записи. Runtime применяет их к отдельной копии varset выбранного поддержанного `Handler(19)` и числовых literal assignments; прочая форма
клана лишь после успешного прохода всего события. Отсутствующий `Init` даёт останавливает preview на `Unsupported`. Runtime применяет упорядоченные записи
к отдельной копии varset выбранного клана лишь после успешного прохода всего
события. Отсутствующий `Init` даёт
статус `Missing` и не создаёт записей инициализации; пустой первый `Init` даёт статус `Missing` и не создаёт записей инициализации; пустой первый `Init` даёт
`Executed` без записей. Если форма инструкции пока не поддержана, package `Executed` без записей. Если форма инструкции пока не поддержана, package
остаётся доступен в preview режиме со статусом `Unsupported`, а default-значения остаётся доступен в preview режиме со статусом `Unsupported`, а default-значения
@@ -375,6 +380,77 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D
Это правила ограниченного Rust-исполнителя, а не обнаруженное поведение Это правила ограниченного Rust-исполнителя, а не обнаруженное поведение
оригинального dispatcher-а. оригинального dispatcher-а.
#### Числовые литералы `.fml` в `Init`
В той же сборке `ai.dll` (`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`)
загрузчик `FUN_10001000` открывает `.fml` рядом с базой каждого `.scr`.
`FUN_10018f40` читает строки в таблицу `FUNCTION`; vtable slot `+0x18`
компилирует сохранённые строки через `FUN_100152a0` (RVA `0x152a0`).
Индексы формул — порядковые номера принятых строк, поэтому строки после
используемого номера тоже остаются в таблице. Native reader обрабатывает
независимые чанки до 255 байт, включая LF; CRLF нормализуется до LF, `Ctrl-Z`
завершает ввод, а NUL скрывает остаток своего чанка для C-string поиска.
Искатель учитывает регистр, ищет подстроку `FUNCTION` и независимо выбирает
первую `(` и запятые в видимом чанке. Это permissive framing, а не строгий
разбор сбалансированного вызова. Rust сохраняет принятые записи в этом порядке
и разбирает выражение только при ссылке из `Init`; отсутствующая соседняя `.fml`
не мешает одному лишь `Handler(19)`, но ошибка чтения, отличная от `NotFound`,
остаётся ошибкой VFS.
Native selector `-1` при source `-1` и нулевой глубине условий выбирает
formula assignment; в native record `header_words[1]` — индекс целевой varset
ячейки, а `header_words[6]` — индекс formula row. `FUN_100123b0` (RVA `0x123b0`) проверяет индекс строки,
`FUN_10015b30` (RVA `0x15b30`) возвращает результат единственного literal node
kind `3`, а `FUN_10013650` передаёт его типизированной ячейке через
`FUN_10012c00` (RVA `0x12c00`). Компилятор `FUN_100152a0` получает числовой
prefix как `double` через `FUN_10020af0` (RVA `0x20af0`), затем записывает
его как `float` в node `+0x10`. Rust ограничивает эту ветвь одним
digit-starting числовым токеном с необязательным хвостом пробельных байтов;
ведущие unary `+`/`-`, variable references, операторы и другие node kinds
остаются явными `UnsupportedExpression`.
Проверенный native tokenizer отличается от обычного строгого decimal parser:
`1e-2`, `1.`, `1e`, `1e+`, `1ee2` и `1e2.3` были приняты как один literal node
и дали payload `0x3c23d70a`, `0x3f800000`, `0x3f800000`, `0x3f800000`,
`0x3f800000` и `0x42c80000`. Для четырёх неполных exponent-форм значение
получено из распознаваемого числового prefix; это именно проверенные строки,
а не полное описание всех ошибок компилятора.
Тип целевой ячейки сохраняется при записи: `float` (native tag `3`) принимает
исходные `f32` bits; `DWORD` (tag `5`) проходит через `__ftol` (RVA `0x1df70`)
с усечением к нулю и записывает младший DWORD. Поэтому literal `0.8` остаётся
`0x3f4ccccd` в `float`, но записывается как `0` в `DWORD`; `100.0` даёт raw
DWORD `100`. Неизвестные выражения не заменяются нулём, а запись всего первого
`Init` применяется только после успешного прохода.
Native x86 oracle на GOG `ai.dll` SHA-256
`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` загрузил
реальный shared `varset.var` (231 declaration) и реальные таблицы
`tut1_pl2.fml` (26 строк), `scr_pl_1.fml` (5 строк) и `scream.fml` (149 строк).
В первой кампании formula `0` записывает нули в DWORD `df0..df3` (indices
188–191); в `Single.01` formula `0` записывает нули в `dT8` и `dT9` (indices
186–187). Строки `scream` `0..8` задают следующие пары literal → raw cell value:
| Formula | Target | Literal → raw cell value |
| ---: | --- | --- |
| 0 | `dMaxBuilder` (`DWORD`) | `0.4 → 0` |
| 1 | `fDifficulty` (`float`) | `0.8 → 0x3f4ccccd` |
| 2 | `dCurrentSender` (`DWORD`) | `0.5 → 0` |
| 3 | `dMaxTower` (`DWORD`) | `1.0 → 1` |
| 4 | `dMaxStorage` (`DWORD`) | `10 → 10` |
| 5 | `dMaxTransport` (`DWORD`) | `0.2 → 0` |
| 6 | `dMaxMine` (`DWORD`) | `100 → 100` |
| 7 | `dMaxPlant` (`DWORD`) | `400 → 400` |
| 8 | `d7` (`DWORD`) | `2 → 2` |
Rust сохраняет всю таблицу строк, но вычисляет только формулу, на которую
ссылается поддержанная запись. Лицензированный runtime-тест подтверждает
полную загрузку кампании Mission.01 после этих присваиваний; у обычной
`Single.01` clan 1 после формул остаётся `Handler(43)` в instruction 10, который
не поддержан и не обходится. Preview оставляет значения этого клана без
частичных записей, а полный load возвращает ошибку транзакционно. Это не
подтверждение запуска самой игры или полной VM.
### Первый вызов Behavior и сценарного `Init` ### Первый вызов Behavior и сценарного `Init`
В той же GOG-сборке `Behavior.dll` (SHA-256 В той же GOG-сборке `Behavior.dll` (SHA-256
@@ -622,10 +698,10 @@ tie-векторов подтверждает реакцию `FISTP`, но не
Нижнеуровневый `VarSet::resolve_handler19` принимает уже преобразованные первые Нижнеуровневый `VarSet::resolve_handler19` принимает уже преобразованные первые
два слова и третье исходное слово, выдаёт три типизированные записи и отклоняет два слова и третье исходное слово, выдаёт три типизированные записи и отклоняет
отсутствующие, выходящие за диапазон или не-`DWORD` цели. Метод отсутствующие, выходящие за диапазон или не-`DWORD` цели. Общий для первого
`resolve_handler19_init` добавляет выбор события и остановку на неподдержанной события метод `VarSet::resolve_init` добавляет этот `Handler(19)` и формулу из
форме инструкции. Всё остальное содержимое `.scr` сохраняется reader-ом, но одного numeric literal; остальные формы инструкции и содержимое `.scr`
остаётся вне этого исполнения. сохраняются reader-ом, но остаются вне этого исполнения.
### Изменение provider-состояния Control ### Изменение provider-состояния Control
+22 -5
View File
@@ -168,8 +168,9 @@ handler лишь инициирует execution context (`+0x50 = 1`); его ga
младшие 32 бита, что даёт `0`. Значение `10001` также проходит дальше: оригинал младшие 32 бита, что даёт `0`. Значение `10001` также проходит дальше: оригинал
показывает предупреждение для чисел выше `10000`, но не отменяет создание AI. показывает предупреждение для чисел выше `10000`, но не отменяет создание AI.
Rust исполняет только подтверждённую форму этого действия. Поддержанные записи Rust исполняет подтверждённый поднабор первого `Init`: действие `Handler(19)`
применяются к состоянию лишь после успешного прохода всего первого `Init`. и числовые literal assignments, описанные ниже. Поддержанные записи применяются
к состоянию лишь после успешного прохода всего первого `Init`.
Если встретилась неподдержанная форма, preview сохраняет пакет, сообщает Если встретилась неподдержанная форма, preview сохраняет пакет, сообщает
`Unsupported` и оставляет значения по умолчанию; при полной загрузке та же форма `Unsupported` и оставляет значения по умолчанию; при полной загрузке та же форма
становится ошибкой. Это политика Rust для ограниченного исполнителя, а не правило становится ошибкой. Это политика Rust для ограниченного исполнителя, а не правило
@@ -177,6 +178,21 @@ Rust исполняет только подтверждённую форму э
событие считается исполненным без записей. Остальные события пока не образуют событие считается исполненным без записей. Остальные события пока не образуют
исполняемую VM. исполняемую VM.
В `Init` встречаются и отдельные присваивания результатов формул. Их запись
выбирает строку формулы по порядковому номеру из соседнего `.fml`; этот файл
относится к конкретной базе сценария, а строки после нужного номера сохраняют свои позиции.
Для первой кампанийной миссии `Mission.01` (`tut1_pl2.fml`) строка 0 записывает
нули в `df0..df3`; в миссии `Single.01` (`scr_pl_1.fml`) строка 0 записывает
нули в `dT8` и `dT9`.
Клан 1 той же миссии использует первые девять строк `scream.fml`: восемь
проверенных значений попадают в `DWORD` с усечением дробной части, а `0.8`
сохраняется как число `float`. Пока Rust вычисляет только числовой литерал,
начинающийся с цифры; операторы, ссылки на переменные и другие формы формул
остаются явными ошибками. В тесте полной загрузки миссия `Mission.01`
проходит этот `Init`; `Single.01` останавливается на следующем неподдержанном
`Handler(43)` в clan 1, записи 10. Никакое значение не подставляется вместо
этого действия.
### Первый запуск сценария ### Первый запуск сценария
До регулярной работы Behavior настраивает расписание своего первого шага. Он До регулярной работы Behavior настраивает расписание своего первого шага. Он
@@ -196,9 +212,10 @@ SuperAI для записей кланов, затем обновляет Behavi
от повторного входа; при занятой защите вызов пропускается, но одноразовая метка от повторного входа; при занятой защите вызов пропускается, но одноразовая метка
всё равно устанавливается. В полной загрузке Rust вызывающая сторона передаёт всё равно устанавливается. В полной загрузке Rust вызывающая сторона передаёт
значение времени очереди; после регистрации объектов загрузчик последовательно значение времени очереди; после регистрации объектов загрузчик последовательно
подготавливает каждый сценарный пакет и выполняет только подтверждённые действия подготавливает каждый сценарный пакет и выполняет восстановленные действия
`Handler(19)` из первого `Init`. Статический просмотр не запускает Behavior, но `Handler(19)` и числовые literal assignments из первого `Init`. Статический
может сохранить подтверждённые записи `Init` в сценарном varset. просмотр не запускает Behavior, но может сохранить эти записи `Init` в сценарном
varset.
Это полезное начало сценария, но ещё не целая игровая жизнь AI. Текущий runtime Это полезное начало сценария, но ещё не целая игровая жизнь AI. Текущий runtime
не воспроизводит аргумент обратного вызова в `AI+0x3fc`, защиту от повторного не воспроизводит аргумент обратного вызова в `AI+0x3fc`, защиту от повторного