aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--crates/fparkan-runtime/src/lib.rs808
-rw-r--r--crates/fparkan-script/src/lib.rs486
-rw-r--r--docs/appendices/knowledge-boundaries.md139
-rw-r--r--docs/appendices/script-vm.md97
-rw-r--r--docs/tomes/06-behavior.md90
5 files changed, 1453 insertions, 167 deletions
diff --git a/crates/fparkan-runtime/src/lib.rs b/crates/fparkan-runtime/src/lib.rs
index 9013019..c962649 100644
--- a/crates/fparkan-runtime/src/lib.rs
+++ b/crates/fparkan-runtime/src/lib.rs
@@ -44,6 +44,7 @@ pub use fparkan_assets::MissionAssets;
pub use mesh_collision::{query_building_segment_world, BuildingMeshHit, BuildingMeshNode};
const MISSION_DECODED_PAYLOAD_CACHE_ENTRIES: usize = 256;
+const MAX_MISSION_AI_RECORD_SLOTS: usize = 1_000_000;
const FALLBACK_SCRIPT_VARSET_PATH: &str = "MISSIONS/SCRIPTS/varset.var";
const INITIAL_BEHAVIOR_RNG_STATE: u32 = 1;
const UNIT_DAT_CONTROL_CLASS_HIGH_BIT: u32 = 0x8000_0000;
@@ -712,6 +713,15 @@ pub struct MissionScriptBundle {
pub behavior_warm_start: Option<BehaviorWarmStartState>,
}
+/// Initial native record-slot words owned by one mission clan's AI instance.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct MissionAiStartupState {
+ /// TMA clan index that owns this AI instance.
+ pub clan_index: usize,
+ /// Sentinel words appended during AI startup, in native insertion order.
+ pub record_slots: Vec<u32>,
+}
+
/// One decoded research table associated with its TMA clan owner and source path.
#[derive(Debug)]
pub struct MissionResearchTrfTable {
@@ -879,6 +889,7 @@ struct MissionLoadOptions {
#[derive(Clone, Debug, Default, Eq, PartialEq)]
struct LoadedMissionScripts {
bundles: Vec<MissionScriptBundle>,
+ ai_startup_states: Vec<MissionAiStartupState>,
varset: Option<MissionScriptVarSet>,
init_states: Vec<MissionScriptInitState>,
varset_states: Vec<MissionScriptVarSetState>,
@@ -1008,6 +1019,7 @@ struct LoadedMissionState {
control_device_tables: Vec<MissionControlDeviceTableState>,
behavior_control_groupings: Vec<MissionBehaviorControlGroupingState>,
research_trf_tables: Vec<MissionResearchTrfTable>,
+ ai_startup_states: Vec<MissionAiStartupState>,
script_bundles: Vec<MissionScriptBundle>,
script_varset: Option<MissionScriptVarSet>,
script_init_states: Vec<MissionScriptInitState>,
@@ -1079,6 +1091,17 @@ pub enum EngineError {
/// Handler-specific diagnostic.
message: String,
},
+ /// A clan's initial AI record slots exceeded the Rust allocation budget or could not be reserved.
+ MissionAiRecordSlots {
+ /// Owning TMA clan index.
+ clan_index: usize,
+ /// Raw second-resource tag interpreted as unsigned bits.
+ raw_count: u32,
+ /// Slots already reserved for earlier clans in this mission.
+ allocated_slots: usize,
+ /// Budget or allocation diagnostic.
+ message: String,
+ },
/// `NRes` decode error.
Nres {
/// Resource path.
@@ -1258,6 +1281,15 @@ impl std::fmt::Display for EngineError {
clan_index,
message,
} => write!(f, "clan {clan_index}: script runtime failed: {message}"),
+ Self::MissionAiRecordSlots {
+ clan_index,
+ raw_count,
+ allocated_slots,
+ message,
+ } => write!(
+ f,
+ "clan {clan_index} AI initial slots (unsigned raw count {raw_count}, after {allocated_slots} mission slots): {message}"
+ ),
Self::Nres { path, source } => write!(f, "{path}: {source}"),
Self::ResearchTrf {
clan_index,
@@ -1318,6 +1350,7 @@ impl std::error::Error for EngineError {
| Self::VarSet { .. }
| Self::ScriptInit { .. }
| Self::ScriptRuntime { .. }
+ | Self::MissionAiRecordSlots { .. }
| Self::Movement(_)
| Self::ResearchTrfPath { .. }
| Self::PrototypeGraph { .. }
@@ -1477,7 +1510,7 @@ fn load_mission_with_options(
let research_trf_tables = if options.asset_scope == MissionAssetScope::Full
&& startup_input.is_some_and(|input| input.research_creation_gate)
{
- load_mission_research_trf_tables(&vfs, &mission, &loaded_scripts.bundles)?
+ load_mission_research_trf_tables(&vfs, &mission, &loaded_scripts.ai_startup_states)?
} else {
Vec::new()
};
@@ -1844,6 +1877,7 @@ fn load_mission_with_options(
control_device_tables,
behavior_control_groupings,
research_trf_tables,
+ ai_startup_states: loaded_scripts.ai_startup_states,
script_bundles: loaded_scripts.bundles,
script_varset: loaded_scripts.varset,
script_init_states: loaded_scripts.init_states,
@@ -1925,6 +1959,15 @@ pub fn loaded_mission_script_bundles(engine: &Engine) -> Option<&[MissionScriptB
.map(|state| state.script_bundles.as_slice())
}
+/// Returns the per-clan AI record slots initialized during full mission startup.
+#[must_use]
+pub fn loaded_mission_ai_startup_states(engine: &Engine) -> Option<&[MissionAiStartupState]> {
+ engine
+ .loaded
+ .as_ref()
+ .map(|state| state.ai_startup_states.as_slice())
+}
+
/// Returns the per-clan TRF tables loaded during full mission startup.
#[must_use]
pub fn loaded_mission_research_trf_tables(engine: &Engine) -> Option<&[MissionResearchTrfTable]> {
@@ -6570,14 +6613,14 @@ fn load_script_formula_table(
fn load_mission_research_trf_tables(
vfs: &Arc<dyn Vfs>,
mission: &MissionDocument,
- bundles: &[MissionScriptBundle],
+ ai_startup_states: &[MissionAiStartupState],
) -> Result<Vec<MissionResearchTrfTable>, EngineError> {
- let mut tables = Vec::with_capacity(bundles.len());
- for bundle in bundles {
- let Some(clan) = mission.clans.get(bundle.clan_index) else {
+ let mut tables = Vec::with_capacity(ai_startup_states.len());
+ for ai_state in ai_startup_states {
+ let Some(clan) = mission.clans.get(ai_state.clan_index) else {
return Err(EngineError::ScriptRuntime {
- clan_index: bundle.clan_index,
- message: "script bundle clan index is outside the decoded mission".to_string(),
+ clan_index: ai_state.clan_index,
+ message: "AI startup state clan index is outside the decoded mission".to_string(),
});
};
let second_resource = match &clan.body {
@@ -6599,7 +6642,7 @@ fn load_mission_research_trf_tables(
}
if !raw_path.is_ascii() {
return Err(EngineError::ResearchTrfPath {
- clan_index: bundle.clan_index,
+ clan_index: ai_state.clan_index,
path: raw_path.to_vec(),
});
}
@@ -6608,12 +6651,12 @@ fn load_mission_research_trf_tables(
let bytes = read_vfs(vfs, &path)?;
let document =
ResearchTrfDocument::decode(bytes).map_err(|source| EngineError::ResearchTrf {
- clan_index: bundle.clan_index,
+ clan_index: ai_state.clan_index,
path: path.as_str().to_string(),
source,
})?;
tables.push(MissionResearchTrfTable {
- clan_index: bundle.clan_index,
+ clan_index: ai_state.clan_index,
path: path.as_str().to_string(),
document,
});
@@ -6629,6 +6672,54 @@ fn load_mission_scripts(
let script_bases = mission_script_bundle_bases(mission);
let mut bundles = Vec::with_capacity(script_bases.len());
let mut first_base_path = None;
+ let mut ai_startup_states = Vec::new();
+ if asset_scope == MissionAssetScope::Full {
+ let mut allocated_slots = 0usize;
+ for (clan_index, clan) in mission.clans.iter().enumerate() {
+ let second_resource = match &clan.body {
+ ClanBody::Standard {
+ second_resource, ..
+ }
+ | ClanBody::Spatial {
+ second_resource, ..
+ } => second_resource,
+ };
+ let raw_count = second_resource.tag as u32;
+ let count =
+ usize::try_from(raw_count).map_err(|_| EngineError::MissionAiRecordSlots {
+ clan_index,
+ raw_count,
+ allocated_slots,
+ message: "count does not fit this platform's address space".to_string(),
+ })?;
+ let requested_total = allocated_slots.checked_add(count);
+ if requested_total.is_none_or(|total| total > MAX_MISSION_AI_RECORD_SLOTS) {
+ return Err(EngineError::MissionAiRecordSlots {
+ clan_index,
+ raw_count,
+ allocated_slots,
+ message: format!(
+ "cumulative mission count exceeds the Rust allocation budget of {MAX_MISSION_AI_RECORD_SLOTS} u32 slots"
+ ),
+ });
+ }
+ let mut record_slots = Vec::new();
+ record_slots.try_reserve_exact(count).map_err(|source| {
+ EngineError::MissionAiRecordSlots {
+ clan_index,
+ raw_count,
+ allocated_slots,
+ message: format!("could not reserve {count} u32 slots: {source}"),
+ }
+ })?;
+ record_slots.resize(count, u32::MAX);
+ allocated_slots = requested_total.expect("checked cumulative AI slot count");
+ ai_startup_states.push(MissionAiStartupState {
+ clan_index,
+ record_slots,
+ });
+ }
+ }
for script_base in script_bases {
let base_path = String::from_utf8_lossy(&script_base.path_raw).into_owned();
let script_path = if base_path.to_ascii_lowercase().ends_with(".scr") {
@@ -6680,6 +6771,7 @@ fn load_mission_scripts(
};
Ok(LoadedMissionScripts {
bundles,
+ ai_startup_states,
varset,
init_states,
varset_states,
@@ -6922,7 +7014,6 @@ fn read_vfs(vfs: &Arc<dyn Vfs>, path: &NormalizedPath) -> Result<Arc<[u8]>, Engi
#[cfg(test)]
mod tests {
use super::*;
- use fparkan_script::ScriptDispatchSelector;
use fparkan_vfs::{DirectoryVfs, MemoryVfs, OverlayVfs, VfsEntry, VfsMetadata};
use std::path::{Path, PathBuf};
@@ -10254,6 +10345,7 @@ mod tests {
};
let mut scripts = LoadedMissionScripts {
bundles: vec![bundle(0, 18, Vec::new()), bundle(1, 19, vec![0, 1, 2])],
+ ai_startup_states: Vec::new(),
varset: Some(varset),
..LoadedMissionScripts::default()
};
@@ -10283,6 +10375,193 @@ mod tests {
assert_eq!(rng_state, 0x0029_e2c0);
}
+ fn script_slots_test_mission(clans: &[(bool, bool, i32)]) -> MissionDocument {
+ fn push_u32(bytes: &mut Vec<u8>, value: u32) {
+ 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);
+ }
+
+ let mut tma = Vec::new();
+ push_u32(&mut tma, 1);
+ push_u32(&mut tma, 0);
+ push_u32(&mut tma, 6);
+ push_u32(
+ &mut tma,
+ u32::try_from(clans.len()).expect("clan count fits u32"),
+ );
+ for (clan_index, (spatial, scripted, tag)) in clans.iter().enumerate() {
+ push_lp(&mut tma, b"synthetic");
+ push_u32(&mut tma, u32::MAX);
+ tma.extend_from_slice(&(clan_index as f32).to_le_bytes());
+ tma.extend_from_slice(&0.0_f32.to_le_bytes());
+ let script_path: &[u8] = if *scripted {
+ b"MISSIONS/SCRIPTS/shared"
+ } else {
+ b""
+ };
+ if *spatial {
+ push_u32(&mut tma, 0);
+ push_lp(&mut tma, script_path);
+ push_u32(&mut tma, 0);
+ } else {
+ push_u32(&mut tma, 1);
+ push_lp(&mut tma, script_path);
+ push_u32(&mut tma, 0);
+ }
+ push_lp(&mut tma, b"unused.trf");
+ push_u32(&mut tma, *tag as u32);
+ push_u32(&mut tma, 0);
+ }
+ push_u32(&mut tma, 10);
+ push_u32(&mut tma, 0);
+ push_lp(&mut tma, b"");
+ push_u32(&mut tma, 0);
+ push_lp(&mut tma, b"");
+ push_u32(&mut tma, 1);
+ push_u32(&mut tma, 0);
+ decode_mission_payload(Arc::from(tma), TmaProfile::Strict).expect("synthetic mission TMA")
+ }
+
+ fn script_slots_test_tags(mission: &MissionDocument) -> Vec<i32> {
+ mission
+ .clans
+ .iter()
+ .map(|clan| match &clan.body {
+ ClanBody::Standard {
+ second_resource, ..
+ }
+ | ClanBody::Spatial {
+ second_resource, ..
+ } => second_resource.tag,
+ })
+ .collect()
+ }
+
+ fn script_slots_test_vfs() -> Arc<dyn Vfs> {
+ let package = encode_script_package(&ScriptPackage {
+ opcode_handler_count: 73,
+ events: Vec::new(),
+ trailing_bytes: Vec::new(),
+ raw: Arc::from(&b""[..]),
+ });
+ let mut files = MemoryVfs::default();
+ files.insert(
+ normalize_engine_path("script slots test", "MISSIONS/SCRIPTS/shared.scr")
+ .expect("script path"),
+ Arc::from(package.into_boxed_slice()),
+ );
+ files.insert(
+ normalize_engine_path("script slots test", "MISSIONS/SCRIPTS/shared.var")
+ .expect("varset path"),
+ Arc::from(&b"VAR(DWORD, value, 0)\n"[..]),
+ );
+ Arc::new(files)
+ }
+
+ #[test]
+ fn full_mission_script_loader_builds_independent_unsigned_ai_slots_per_clan() {
+ let mission = script_slots_test_mission(&[
+ (false, true, 2),
+ (true, true, 3),
+ (false, false, 4),
+ (false, true, 0),
+ ]);
+ let raw_tags = script_slots_test_tags(&mission);
+ let vfs = script_slots_test_vfs();
+
+ let full = load_mission_scripts(&vfs, &mission, MissionAssetScope::Full)
+ .expect("Full script bundle load");
+ assert_eq!(
+ full.bundles
+ .iter()
+ .map(|bundle| bundle.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1, 3],
+ "empty-script clans do not produce fake script bundles"
+ );
+ assert_eq!(
+ full.ai_startup_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1, 2, 3],
+ "Full startup retains a state for every TMA clan"
+ );
+ assert_eq!(
+ full.ai_startup_states
+ .iter()
+ .map(|state| state.record_slots.clone())
+ .collect::<Vec<_>>(),
+ [
+ vec![u32::MAX; 2],
+ vec![u32::MAX; 3],
+ vec![u32::MAX; 4],
+ Vec::new(),
+ ]
+ );
+ assert_ne!(
+ full.ai_startup_states[0].record_slots.as_ptr(),
+ full.ai_startup_states[1].record_slots.as_ptr(),
+ "each clan AI owns a separate vector"
+ );
+ assert_ne!(
+ full.ai_startup_states[1].record_slots.as_ptr(),
+ full.ai_startup_states[2].record_slots.as_ptr(),
+ "empty-script AI also owns a separate vector"
+ );
+ assert!(full
+ .bundles
+ .iter()
+ .all(|bundle| bundle.init_execution == MissionScriptInitExecution::Missing));
+
+ let preview = load_mission_scripts(&vfs, &mission, MissionAssetScope::FirstMeshPreview)
+ .expect("preview script bundle load");
+ assert!(preview.ai_startup_states.is_empty());
+ assert_eq!(
+ preview
+ .bundles
+ .iter()
+ .map(|bundle| bundle.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1, 3]
+ );
+ assert_eq!(
+ script_slots_test_tags(&mission),
+ raw_tags,
+ "loading leaves the signed TMA tag bits unchanged"
+ );
+ }
+
+ #[test]
+ fn full_mission_script_loader_rejects_unsigned_and_cumulative_ai_slot_overflow() {
+ let vfs = script_slots_test_vfs();
+
+ let negative_tag = script_slots_test_mission(&[(false, false, -1)]);
+ assert!(matches!(
+ load_mission_scripts(&vfs, &negative_tag, MissionAssetScope::Full),
+ Err(EngineError::MissionAiRecordSlots {
+ clan_index: 0,
+ raw_count: u32::MAX,
+ ..
+ })
+ ));
+
+ let over_budget =
+ script_slots_test_mission(&[(false, true, 600_000), (true, false, 400_001)]);
+ assert!(matches!(
+ load_mission_scripts(&vfs, &over_budget, MissionAssetScope::Full),
+ Err(EngineError::MissionAiRecordSlots {
+ clan_index: 1,
+ raw_count: 400_001,
+ allocated_slots: 600_000,
+ ..
+ })
+ ));
+ }
+
#[test]
fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() {
let mut engine = create(EngineServices::default()).expect("engine");
@@ -13070,12 +13349,14 @@ mod tests {
(
"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"[..],
+ &b"VAR(DWORD, ClanBaseX, 0)\nVAR(DWORD, ClanBaseY, 0)\nVAR(DWORD, ClanID, 99)\nVAR(float, target, 0.5)\n"[..],
),
),
(
"MISSIONS/SCRIPTS/formula.fml",
- Arc::from(&b"// authored literal\n\nFUNCTION( , 0.8, )\nFUNCTION( later, target + 2, )\n"[..]),
+ Arc::from(
+ &b"// authored Init expression\n\nFUNCTION( , ClanID + 0.8, )\nFUNCTION( later, target * 2, )\n"[..],
+ ),
),
] {
files.insert(
@@ -13112,6 +13393,185 @@ mod tests {
}
#[test]
+ #[ignore = "requires licensed corpus"]
+ fn licensed_fml_variable_rows_resolve_from_shared_varset_defaults() {
+ fn formula_action(target: u32, row: u32) -> ScriptInstruction {
+ ScriptInstruction {
+ header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, row],
+ references: Vec::new(),
+ }
+ }
+
+ fn init_event_index(package: &ScriptPackage) -> usize {
+ package
+ .events
+ .iter()
+ .position(|event| {
+ event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..])
+ })
+ .expect("Init event")
+ }
+
+ fn row_is_assigned(instructions: &[ScriptInstruction], row: u32, target: u32) -> bool {
+ instructions.iter().any(|instruction| {
+ instruction.header_words[0] == u32::MAX
+ && instruction.header_words[1] == target
+ && instruction.header_words[2] == u32::MAX
+ && instruction.header_words[3] == u32::MAX
+ && instruction.header_words[6] == row
+ })
+ }
+
+ let root = licensed_root("IS");
+ let script_dir = root.join("MISSIONS/SCRIPTS");
+ let declarations = fparkan_script::parse_varset(
+ &std::fs::read(script_dir.join("varset.var")).expect("read shared varset"),
+ )
+ .expect("parse shared varset");
+ let formulas3 = FormulaTable::parse(
+ &std::fs::read(script_dir.join("c1m3e.fml")).expect("read c1m3e.fml"),
+ );
+ let package3 = fparkan_script::decode(
+ &std::fs::read(script_dir.join("c1m3e.scr")).expect("read c1m3e.scr"),
+ )
+ .expect("decode c1m3e.scr");
+ let init3_index = init_event_index(&package3);
+ let init3_event_word = package3.events[init3_index].event_word;
+ let init3 = &package3.events[init3_index].instructions;
+ assert!(row_is_assigned(init3, 0, 40));
+ assert!(row_is_assigned(init3, 1, 41));
+ assert!(row_is_assigned(init3, 2, 164));
+ assert!(!row_is_assigned(init3, 19, 158));
+ assert!(!row_is_assigned(init3, 22, 174));
+ assert!(row_is_assigned(
+ &package3
+ .events
+ .iter()
+ .find(|event| event.name_raw.split(|byte| *byte == 0).next()
+ == Some(&b"Fort_Captured"[..]))
+ .expect("Fort_Captured event")
+ .instructions,
+ 19,
+ 158,
+ ));
+ assert!(row_is_assigned(
+ &package3
+ .events
+ .iter()
+ .find(|event| event.name_raw.split(|byte| *byte == 0).next()
+ == Some(&b"PBM_BUILDING_PROTECT_Start"[..]))
+ .expect("building-protection event")
+ .instructions,
+ 22,
+ 174,
+ ));
+ assert_eq!(formulas3.rows[19].expression_raw, b" 0.8 + fT");
+ assert_eq!(
+ formulas3.rows[22].expression_raw,
+ b" dBuildingProtectHits - dT2"
+ );
+
+ let f_temp = u32::try_from(
+ declarations
+ .declarations
+ .iter()
+ .position(|declaration| declaration.name == "fTemp")
+ .expect("fTemp declaration"),
+ )
+ .expect("fTemp index fits u32");
+ let d_temp3 = u32::try_from(
+ declarations
+ .declarations
+ .iter()
+ .position(|declaration| declaration.name == "dTemp3")
+ .expect("dTemp3 declaration"),
+ )
+ .expect("dTemp3 index fits u32");
+ assert_eq!((f_temp, d_temp3), (158, 174));
+
+ let mut synthetic3 = package3.clone();
+ synthetic3.events[init3_index].instructions =
+ vec![formula_action(f_temp, 19), formula_action(d_temp3, 22)];
+ assert_eq!(
+ declarations.resolve_init(
+ &synthetic3,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas3),
+ ),
+ Ok(ScriptInitResolution::Executed {
+ event_index: init3_index,
+ event_word: init3_event_word,
+ writes: vec![
+ ScriptInitWrite {
+ index: f_temp,
+ value: VarSetDefault::FloatBits(0.8_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: d_temp3,
+ value: VarSetDefault::Dword(2_000_000),
+ },
+ ],
+ })
+ );
+
+ let formulas4 = FormulaTable::parse(
+ &std::fs::read(script_dir.join("c1m4e.fml")).expect("read c1m4e.fml"),
+ );
+ let package4 = fparkan_script::decode(
+ &std::fs::read(script_dir.join("c1m4e.scr")).expect("read c1m4e.scr"),
+ )
+ .expect("decode c1m4e.scr");
+ let init4_index = init_event_index(&package4);
+ let init4_event_word = package4.events[init4_index].event_word;
+ let init4 = &package4.events[init4_index].instructions;
+ assert!(!row_is_assigned(init4, 0, f_temp));
+ assert!(row_is_assigned(
+ &package4
+ .events
+ .iter()
+ .find(|event| event.name_raw.split(|byte| *byte == 0).next()
+ == Some(&b"PBM_BUILDING_INF_CAPTURE_Start"[..]))
+ .expect("building-infantry-capture event")
+ .instructions,
+ 0,
+ f_temp,
+ ));
+ assert_eq!(formulas4.rows[0].expression_raw, b" fTemp + 0.00001");
+ let mut synthetic4 = package4.clone();
+ synthetic4.events[init4_index].instructions =
+ vec![formula_action(f_temp, 0), formula_action(f_temp, 0)];
+ assert_eq!(
+ declarations.resolve_init(
+ &synthetic4,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas4),
+ ),
+ Ok(ScriptInitResolution::Executed {
+ event_index: init4_index,
+ event_word: init4_event_word,
+ writes: vec![
+ ScriptInitWrite {
+ index: f_temp,
+ value: VarSetDefault::FloatBits(0.00001_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: f_temp,
+ value: VarSetDefault::FloatBits((0.00001_f32 + 0.00001_f32).to_bits()),
+ },
+ ],
+ })
+ );
+ }
+
+ #[test]
fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() {
let mut personal_files = MemoryVfs::default();
personal_files.insert(
@@ -13278,6 +13738,8 @@ mod tests {
#[test]
fn handler_nineteen_preview_keeps_unsupported_package_without_partial_writes() {
+ let formula_table =
+ FormulaTable::parse(b"FUNCTION(add, x + 2, )\nFUNCTION(unsupported, x * 2, )\n");
let varset = MissionScriptVarSet {
path: FALLBACK_SCRIPT_VARSET_PATH.to_string(),
used_fallback: true,
@@ -13298,7 +13760,11 @@ mod tests {
references: vec![0, 1, 2],
},
fparkan_script::ScriptInstruction {
- header_words: [18, u32::MAX, u32::MAX, 0, 0, 0, 0],
+ header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, 0],
+ references: Vec::new(),
+ },
+ fparkan_script::ScriptInstruction {
+ header_words: [u32::MAX, 0, u32::MAX, u32::MAX, 6, 0, 1],
references: Vec::new(),
},
],
@@ -13311,7 +13777,7 @@ mod tests {
base_path: "MISSIONS/SCRIPTS/default".to_string(),
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
package: package.clone(),
- formula_table: None,
+ formula_table: Some(formula_table.clone()),
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
};
@@ -13329,9 +13795,10 @@ mod tests {
preview_bundles[0].init_execution,
MissionScriptInitExecution::Unsupported {
event_index: 0,
- instruction_index: 1,
- error: ScriptInitResolveError::UnsupportedSelector {
- found: ScriptDispatchSelector::Handler(18),
+ instruction_index: 2,
+ error: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 1,
+ expression: b" x * 2".to_vec(),
},
}
);
@@ -13360,12 +13827,13 @@ mod tests {
Err(EngineError::ScriptInitUnsupported {
clan_index: 0,
event_index: 0,
- instruction_index: 1,
- source: ScriptInitResolveError::UnsupportedSelector {
- found: ScriptDispatchSelector::Handler(18),
+ instruction_index: 2,
+ source: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 1,
+ expression,
},
..
- })
+ }) if expression == b" x * 2"
));
let mut malformed_package = package.clone();
@@ -15043,6 +15511,50 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
+ fn licensed_single_script_bundle_load_initializes_ai_slots_before_later_init_work() {
+ let root = licensed_root("IS");
+ let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
+ let path = normalize_engine_path("mission", "MISSIONS/Single.01/data.tma")
+ .expect("Single.01 path");
+ let mission = decode_mission_payload(
+ read_vfs(&vfs, &path).expect("Single.01 TMA"),
+ TmaProfile::Strict,
+ )
+ .expect("decode Single.01 TMA");
+ assert_eq!(script_slots_test_tags(&mission), [9, 9]);
+
+ let full = load_mission_scripts(&vfs, &mission, MissionAssetScope::Full)
+ .expect("Full script bundle load");
+ assert_eq!(
+ full.ai_startup_states
+ .iter()
+ .map(|state| state.record_slots.len())
+ .collect::<Vec<_>>(),
+ [9, 9]
+ );
+ assert_eq!(
+ full.ai_startup_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1]
+ );
+ assert!(full
+ .ai_startup_states
+ .iter()
+ .all(|state| { state.record_slots == [u32::MAX; 9] }));
+ assert!(full
+ .bundles
+ .iter()
+ .all(|bundle| bundle.init_execution == MissionScriptInitExecution::Missing));
+
+ let preview = load_mission_scripts(&vfs, &mission, MissionAssetScope::FirstMeshPreview)
+ .expect("preview script bundle load");
+ assert!(preview.ai_startup_states.is_empty());
+ }
+
+ #[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));
@@ -15465,16 +15977,31 @@ mod tests {
.iter()
.position(|event| event.name_raw.split(|byte| *byte == 0).next() == Some(&b"Init"[..]))
.expect("Init event");
- assert_eq!(
- package.events[init_index].instructions[0].header_words[0],
- 19
+ let init = &mut package.events[init_index];
+ let handler = init
+ .instructions
+ .first()
+ .expect("original Handler(19) instruction");
+ assert_eq!(handler.header_words[0], 19);
+ assert_eq!(handler.references.as_slice(), &[224, 225, 226]);
+ let original_instruction_count = init.instructions.len();
+ init.instructions.push(ScriptInstruction {
+ header_words: [u32::MAX, 164, u32::MAX, u32::MAX, 6, 0, 0],
+ references: Vec::new(),
+ });
+ init.instructions.push(ScriptInstruction {
+ header_words: [u32::MAX, 158, u32::MAX, u32::MAX, 6, 0, 1],
+ references: Vec::new(),
+ });
+ let replacement_script = Arc::<[u8]>::from(encode_script_package(&package));
+ let replacement_formula = Arc::<[u8]>::from(
+ &b"FUNCTION(add, ClanBaseX + 0.8, )\nFUNCTION(unsupported, fT * 2, )\n"[..],
);
- package.events[init_index].instructions[0].header_words[0] = 18;
- let replacement = Arc::<[u8]>::from(encode_script_package(&package));
let vfs = Arc::new(SwappingScriptVfs {
inner: DirectoryVfs::new(&root),
- replacement,
+ replacement_script,
+ replacement_formula,
enabled: std::sync::atomic::AtomicBool::new(false),
});
let services_vfs: Arc<dyn Vfs> = vfs.clone();
@@ -15519,11 +16046,13 @@ mod tests {
assert!(matches!(
error,
EngineError::ScriptInitUnsupported {
- source: ScriptInitResolveError::UnsupportedSelector {
- found: ScriptDispatchSelector::Handler(18),
+ instruction_index,
+ source: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 1,
+ expression,
},
..
- }
+ } if instruction_index == original_instruction_count + 1 && expression == b" fT * 2"
));
assert_eq!(loaded_mission(&engine), Some(&previous_loaded));
assert_eq!(
@@ -15917,6 +16446,15 @@ mod tests {
research_paths: &[&[u8]],
trf: Option<&[u8]>,
) -> Arc<dyn Vfs> {
+ research_startup_test_vfs_with_raw_tag(script_bases, research_paths, 9, trf)
+ }
+
+ fn research_startup_test_vfs_with_raw_tag(
+ script_bases: &[Option<&[u8]>],
+ research_paths: &[&[u8]],
+ raw_tag: i32,
+ trf: Option<&[u8]>,
+ ) -> Arc<dyn Vfs> {
fn push_u32(bytes: &mut Vec<u8>, value: u32) {
bytes.extend_from_slice(&value.to_le_bytes());
}
@@ -15944,7 +16482,7 @@ mod tests {
push_lp(&mut mission, script_base.unwrap_or_default());
push_u32(&mut mission, 0);
push_lp(&mut mission, research_path);
- push_u32(&mut mission, 9);
+ push_u32(&mut mission, raw_tag as u32);
push_u32(&mut mission, 0);
}
push_u32(&mut mission, 10);
@@ -16001,12 +16539,103 @@ mod tests {
}
#[test]
- fn full_load_keeps_independent_trf_tables_for_each_script_bundle_clan() {
+ fn ai_record_slot_budget_error_keeps_loaded_mission_clock_and_rng() {
+ let valid_vfs = research_startup_test_vfs_with_raw_tag(
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b""],
+ 9,
+ None,
+ );
+ let mut engine = create(EngineServices::new(valid_vfs)).expect("startup test engine");
+ let request = || MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ };
+ load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: false,
+ },
+ )
+ .expect("initial valid full load");
+ let previous_mission = loaded_mission(&engine).expect("loaded mission").clone();
+ let previous_bundles = loaded_mission_script_bundles(&engine)
+ .expect("loaded script bundles")
+ .to_vec();
+ let previous_ai_startup_states = loaded_mission_ai_startup_states(&engine)
+ .expect("loaded AI startup states")
+ .to_vec();
+ assert_eq!(previous_ai_startup_states.len(), 2);
+ assert_eq!(
+ previous_ai_startup_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1]
+ );
+ assert!(previous_ai_startup_states
+ .iter()
+ .all(|state| state.record_slots == [u32::MAX; 9]));
+ assert_eq!(previous_bundles.len(), 1);
+ assert_eq!(previous_bundles[0].clan_index, 0);
+ assert_eq!(
+ previous_bundles[0].behavior_warm_start,
+ Some(BehaviorWarmStartState {
+ first_time_ms: 100_000,
+ next_deadline_ms: 100_202,
+ cleared_words: [0; 5],
+ })
+ );
+ let previous_rng = engine.behavior_rng_state;
+ assert_ne!(previous_rng, INITIAL_BEHAVIOR_RNG_STATE);
+ let previous_world = fparkan_world::canonical_state_hash(&engine.world);
+
+ engine.services.vfs = Some(research_startup_test_vfs_with_raw_tag(
+ &[None, None],
+ &[b"", b""],
+ -1,
+ None,
+ ));
+ let error = load_mission(
+ &mut engine,
+ request(),
+ MissionStartupInput {
+ queue_time_ms: 100_500,
+ research_creation_gate: false,
+ },
+ )
+ .expect_err("negative raw tag is unsigned u32::MAX and exceeds the budget");
+
+ assert!(matches!(
+ error,
+ EngineError::MissionAiRecordSlots {
+ clan_index: 0,
+ raw_count: u32::MAX,
+ ..
+ }
+ ));
+ assert_eq!(loaded_mission(&engine), Some(&previous_mission));
+ assert_eq!(
+ loaded_mission_script_bundles(&engine),
+ Some(previous_bundles.as_slice())
+ );
+ assert_eq!(
+ loaded_mission_ai_startup_states(&engine),
+ Some(previous_ai_startup_states.as_slice())
+ );
+ assert_eq!(engine.behavior_rng_state, previous_rng);
+ assert_eq!(
+ fparkan_world::canonical_state_hash(&engine.world),
+ previous_world
+ );
+ }
+
+ #[test]
+ fn full_load_keeps_independent_trf_tables_for_each_ai_state_clan() {
let vfs = research_startup_test_vfs(
- &[
- Some(b"MISSIONS/SCRIPTS/shared"),
- Some(b"MISSIONS/SCRIPTS/shared"),
- ],
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
&[
b"DATA/Research/shared.trf\0ignored.trf",
b"DATA/Research/shared.trf",
@@ -16029,13 +16658,21 @@ mod tests {
.expect("full load with explicit research gate");
let tables = loaded_mission_research_trf_tables(&engine).expect("TRF tables");
- assert_eq!(tables.len(), 2, "each loaded script clan owns a table");
+ assert_eq!(tables.len(), 2, "each AI state owner gets a table");
+ let bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
+ assert_eq!(bundles.len(), 1, "only the scripted clan has a bundle");
+ assert_eq!(bundles[0].clan_index, 0);
+ let ai_states = loaded_mission_ai_startup_states(&engine).expect("AI states");
assert_eq!(
- loaded_mission_script_bundles(&engine)
- .expect("script bundles")
- .len(),
- 2
+ ai_states
+ .iter()
+ .map(|state| state.clan_index)
+ .collect::<Vec<_>>(),
+ [0, 1]
);
+ assert!(ai_states
+ .iter()
+ .all(|state| state.record_slots == [u32::MAX; 9]));
assert_eq!(tables[0].clan_index, 0);
assert_eq!(tables[0].path, "DATA/Research/shared.trf");
assert_eq!(tables[1].clan_index, 1);
@@ -16053,7 +16690,7 @@ mod tests {
}
#[test]
- fn full_load_skips_research_trf_when_gate_bundle_or_c_string_is_missing() {
+ fn full_load_skips_research_trf_when_gate_or_c_string_is_missing() {
for (script_bases, research_paths, gate, mode) in [
(
vec![Some(&b"MISSIONS/SCRIPTS/shared"[..])],
@@ -16073,12 +16710,6 @@ mod tests {
true,
MissionMode::SingleMission,
),
- (
- vec![None],
- vec![&b"DATA/Research/missing.trf"[..]],
- true,
- MissionMode::SingleMission,
- ),
] {
let vfs = research_startup_test_vfs(&script_bases, &research_paths, None);
let mut engine =
@@ -16102,10 +16733,36 @@ mod tests {
}
#[test]
- fn research_trf_read_and_decode_errors_keep_the_loaded_world_and_rng() {
- let initial_vfs = research_startup_test_vfs(
+ fn static_preview_skips_research_trf_reads() {
+ let vfs = research_startup_test_vfs(
&[Some(b"MISSIONS/SCRIPTS/shared")],
- &[b"DATA/Research/shared.trf"],
+ &[b"DATA/Research/missing.trf"],
+ None,
+ );
+ let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine");
+
+ load_mission_static_preview(
+ &mut engine,
+ MissionRequest {
+ key: "MISSIONS/TEST/data.tma".to_string(),
+ mode: MissionMode::SingleMission,
+ },
+ )
+ .expect("preview must not read research TRF paths");
+
+ assert!(loaded_mission_research_trf_tables(&engine)
+ .expect("TRF state")
+ .is_empty());
+ assert!(loaded_mission_ai_startup_states(&engine)
+ .expect("AI state")
+ .is_empty());
+ }
+
+ #[test]
+ fn no_script_ai_research_trf_read_and_decode_errors_keep_loaded_world_and_rng() {
+ let initial_vfs = research_startup_test_vfs(
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b"DATA/Research/shared.trf"],
Some(&research_test_trf()),
);
let mut engine = create(EngineServices::new(initial_vfs)).expect("research startup engine");
@@ -16137,8 +16794,8 @@ mod tests {
for trf in [None, Some(&b"malformed"[..])] {
let vfs = research_startup_test_vfs(
- &[Some(b"MISSIONS/SCRIPTS/shared")],
- &[b"DATA/Research/shared.trf"],
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b"DATA/Research/shared.trf"],
trf,
);
engine.services.vfs = Some(vfs);
@@ -16182,7 +16839,7 @@ mod tests {
clan_index, path, ..
},
) => {
- assert_eq!(clan_index, 0);
+ assert_eq!(clan_index, 1);
assert_eq!(path, "DATA/Research/shared.trf");
}
(_, error) => panic!("unexpected TRF failure: {error:?}"),
@@ -16193,8 +16850,8 @@ mod tests {
#[test]
fn research_trf_c_string_paths_reject_non_ascii_bytes() {
let vfs = research_startup_test_vfs(
- &[Some(b"MISSIONS/SCRIPTS/shared")],
- &[b"DATA/Research/\xe9.trf"],
+ &[Some(b"MISSIONS/SCRIPTS/shared"), None],
+ &[b"", b"DATA/Research/\xe9.trf"],
None,
);
let mut engine = create(EngineServices::new(vfs)).expect("research startup test engine");
@@ -16212,14 +16869,14 @@ mod tests {
.expect_err("non-ASCII TRF path must fail explicitly");
assert!(matches!(
error,
- EngineError::ResearchTrfPath { clan_index: 0, path }
+ EngineError::ResearchTrfPath { clan_index: 1, path }
if path == b"DATA/Research/\xe9.trf"
));
}
#[test]
#[ignore = "requires licensed corpus"]
- fn licensed_campaign_full_load_creates_trf_tables_for_script_clan_owners() {
+ fn licensed_campaign_full_load_creates_trf_tables_for_ai_clan_owners() {
let root = licensed_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
let mut engine = create(EngineServices::new(vfs)).expect("engine");
@@ -16279,24 +16936,37 @@ mod tests {
struct SwappingScriptVfs {
inner: DirectoryVfs,
- replacement: Arc<[u8]>,
+ replacement_script: Arc<[u8]>,
+ replacement_formula: Arc<[u8]>,
enabled: std::sync::atomic::AtomicBool,
}
impl SwappingScriptVfs {
- fn use_replacement(&self, path: &NormalizedPath) -> bool {
- self.enabled.load(std::sync::atomic::Ordering::SeqCst)
- && path
- .as_str()
- .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.scr")
+ fn replacement(&self, path: &NormalizedPath) -> Option<&Arc<[u8]>> {
+ if !self.enabled.load(std::sync::atomic::Ordering::SeqCst) {
+ return None;
+ }
+ if path
+ .as_str()
+ .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.scr")
+ {
+ Some(&self.replacement_script)
+ } else if path
+ .as_str()
+ .eq_ignore_ascii_case("MISSIONS/SCRIPTS/default.fml")
+ {
+ Some(&self.replacement_formula)
+ } else {
+ None
+ }
}
}
impl Vfs for SwappingScriptVfs {
fn metadata(&self, path: &NormalizedPath) -> Result<VfsMetadata, VfsError> {
- if self.use_replacement(path) {
+ if let Some(replacement) = self.replacement(path) {
return Ok(VfsMetadata {
- len: self.replacement.len() as u64,
+ len: replacement.len() as u64,
fingerprint: [0x18; 32],
});
}
@@ -16304,8 +16974,8 @@ mod tests {
}
fn read(&self, path: &NormalizedPath) -> Result<Arc<[u8]>, VfsError> {
- if self.use_replacement(path) {
- return Ok(self.replacement.clone());
+ if let Some(replacement) = self.replacement(path) {
+ return Ok(replacement.clone());
}
self.inner.read(path)
}
diff --git a/crates/fparkan-script/src/lib.rs b/crates/fparkan-script/src/lib.rs
index 7959ce8..20821fe 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 resolver for confirmed `Init` handlers and numeric FML literals.
+//! narrow resolver for confirmed `Init` handlers and bounded FML expressions.
use fparkan_binary::{checked_allocation_len, Cursor, DecodeError, Limits};
use std::sync::Arc;
@@ -214,7 +214,7 @@ pub enum ScriptInitResolveError {
/// Number of preserved FML rows.
rows: usize,
},
- /// The referenced FML row is not one numeric token.
+ /// The referenced FML row is outside the supported atom-expression subset.
UnsupportedFormulaExpression {
/// Zero-based FML row index.
index: u32,
@@ -259,7 +259,7 @@ impl std::fmt::Display for ScriptInitResolveError {
),
Self::UnsupportedFormulaExpression { index, expression } => write!(
formatter,
- "formula {index} expression {:?} is not one numeric literal",
+ "formula {index} expression {:?} is outside the supported atom and binary +/− subset",
String::from_utf8_lossy(expression)
),
Self::DwordConversionOutOfRange { index, value_bits } => write!(
@@ -736,6 +736,7 @@ impl VarSet {
/// 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.
+ /// Formula atoms read defaults and earlier successful writes in this event.
///
/// # Errors
///
@@ -753,10 +754,24 @@ impl VarSet {
return Ok(ScriptInitResolution::Missing);
};
+ let mut values = self
+ .declarations
+ .iter()
+ .map(|declaration| declaration.default_value)
+ .collect::<Vec<_>>();
let mut writes = Vec::new();
for (instruction_index, instruction) in event.instructions.iter().enumerate() {
- match self.resolve_init_instruction(instruction, input, formulas)? {
+ match self.resolve_init_instruction(instruction, input, formulas, &values)? {
InitInstructionResolution::Executed(instruction_writes) => {
+ for write in &instruction_writes {
+ let Some(value) = values.get_mut(write.index as usize) else {
+ return Err(ScriptInitResolveError::VarSetIndexOutOfBounds {
+ index: write.index,
+ declarations: self.declarations.len(),
+ });
+ };
+ *value = write.value;
+ }
writes.extend(instruction_writes);
}
InitInstructionResolution::Unsupported(error) => {
@@ -781,6 +796,7 @@ impl VarSet {
instruction: &ScriptInstruction,
input: Handler19InitInput,
formulas: Option<&FormulaTable>,
+ values: &[VarSetDefault],
) -> Result<InitInstructionResolution, ScriptInitResolveError> {
match instruction.dispatch_selector() {
ScriptDispatchSelector::Handler(19) => {
@@ -842,22 +858,23 @@ impl VarSet {
));
};
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 Some(formula) = formulas.rows.get(formula_index as usize) else {
+ return Err(ScriptInitResolveError::FormulaIndexOutOfBounds {
+ index: formula_index,
+ rows: formulas.rows.len(),
+ });
+ };
+ let Some(value) = parse_init_formula_expression(
+ &formula.expression_raw,
+ &self.declarations,
+ values,
+ ) else {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: formula_index,
+ expression: formula.expression_raw.clone(),
+ },
+ ));
};
let target_index = instruction.header_words[1];
let Some(target) = self.declarations.get(target_index as usize) else {
@@ -866,6 +883,20 @@ impl VarSet {
declarations: self.declarations.len(),
});
};
+ let Some(current_target) = values.get(target_index as usize) else {
+ return Err(ScriptInitResolveError::VarSetIndexOutOfBounds {
+ index: target_index,
+ declarations: values.len(),
+ });
+ };
+ if !varset_value_matches_type(target.type_name, *current_target) {
+ return Ok(InitInstructionResolution::Unsupported(
+ ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: formula_index,
+ expression: formula.expression_raw.clone(),
+ },
+ ));
+ }
let value = match target.type_name {
VarSetType::Float => VarSetDefault::FloatBits(value.to_bits()),
VarSetType::Dword => {
@@ -1357,6 +1388,113 @@ fn native_decimal_prefix_len(token: &[u8]) -> Option<usize> {
Some(index)
}
+fn parse_init_formula_expression(
+ expression: &[u8],
+ declarations: &[VarSetDeclaration],
+ values: &[VarSetDefault],
+) -> Option<f32> {
+ let (left, left_end) = parse_init_formula_atom(expression, 0, declarations, values)?;
+ let operator_index = skip_native_formula_space(expression, left_end);
+ let Some(&operator @ (b'+' | b'-')) = expression.get(operator_index) else {
+ return expression[left_end..]
+ .iter()
+ .all(|byte| *byte == 0 || *byte <= 32)
+ .then_some(left);
+ };
+ let (right, right_end) =
+ parse_init_formula_atom(expression, operator_index + 1, declarations, values)?;
+ if !expression[right_end..]
+ .iter()
+ .all(|byte| *byte == 0 || *byte <= 32)
+ {
+ return None;
+ }
+ Some(match operator {
+ b'+' => right + left,
+ b'-' => left - right,
+ _ => unreachable!(),
+ })
+}
+
+fn parse_init_formula_atom(
+ expression: &[u8],
+ start: usize,
+ declarations: &[VarSetDeclaration],
+ values: &[VarSetDefault],
+) -> Option<(f32, usize)> {
+ let start = skip_native_formula_space(expression, start);
+ let remaining = expression.get(start..)?;
+ let first = *remaining.first()?;
+ let token_len = if first.is_ascii_digit() {
+ native_numeric_token_len(remaining)?
+ } else {
+ native_variable_token_len(remaining)?
+ };
+ let token = &remaining[..token_len];
+ if is_native_operator_atom(token) {
+ return None;
+ }
+
+ let value = if let Some(index) = declarations
+ .iter()
+ .position(|declaration| declaration.name.as_bytes() == token)
+ {
+ let declaration = &declarations[index];
+ let value = *values.get(index)?;
+ varset_value_as_f32(declaration.type_name, value)?
+ } else {
+ parse_native_numeric_literal(token)?
+ };
+ Some((value, start + token_len))
+}
+
+fn native_variable_token_len(token: &[u8]) -> Option<usize> {
+ let first = *token.first()?;
+ if first == b'^' || !is_native_variable_byte(first) {
+ return None;
+ }
+ Some(
+ token
+ .iter()
+ .take_while(|byte| is_native_variable_byte(**byte))
+ .count(),
+ )
+}
+
+fn is_native_variable_byte(byte: u8) -> bool {
+ byte.is_ascii() && ((byte > b'@' && byte < b'{') || byte.is_ascii_digit() || byte == b'_')
+}
+
+fn is_native_operator_atom(token: &[u8]) -> bool {
+ matches!(
+ token,
+ b"+" | b"-" | b"*" | b"/" | b"^" | b"&" | b"|" | b"!" | b"A" | b"B" | b"N" | b"S"
+ )
+}
+
+fn skip_native_formula_space(expression: &[u8], start: usize) -> usize {
+ expression[start..]
+ .iter()
+ .position(|byte| *byte == 0 || *byte > 32)
+ .map_or(expression.len(), |offset| start + offset)
+}
+
+fn varset_value_as_f32(type_name: VarSetType, value: VarSetDefault) -> Option<f32> {
+ match (type_name, value) {
+ (VarSetType::Float, VarSetDefault::FloatBits(bits)) => Some(f32::from_bits(bits)),
+ (VarSetType::Dword, VarSetDefault::Dword(value)) => Some(value as f32),
+ _ => None,
+ }
+}
+
+fn varset_value_matches_type(type_name: VarSetType, value: VarSetDefault) -> bool {
+ matches!(
+ (type_name, value),
+ (VarSetType::Float, VarSetDefault::FloatBits(_))
+ | (VarSetType::Dword, VarSetDefault::Dword(_))
+ )
+}
+
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);
@@ -1822,6 +1960,49 @@ mod tests {
};
use fparkan_binary::{DecodeError, Limits};
+ fn init_package(instructions: Vec<ScriptInstruction>) -> ScriptPackage {
+ ScriptPackage {
+ opcode_handler_count: 73,
+ events: vec![ScriptEvent {
+ name_len: 4,
+ name_raw: b"Init\0".to_vec(),
+ event_word: 0,
+ instructions,
+ }],
+ trailing_bytes: Vec::new(),
+ raw: std::sync::Arc::from(&b""[..]),
+ }
+ }
+
+ fn init_formula_assignment(target: u32, formula: u32) -> ScriptInstruction {
+ ScriptInstruction {
+ header_words: [u32::MAX, target, u32::MAX, u32::MAX, 6, 0, formula],
+ references: Vec::new(),
+ }
+ }
+
+ fn init_handler19() -> ScriptInstruction {
+ ScriptInstruction {
+ header_words: [19, u32::MAX, u32::MAX, u32::MAX, 6, 3, u32::MAX],
+ references: vec![0, 1, 2],
+ }
+ }
+
+ fn init_input() -> Handler19InitInput {
+ Handler19InitInput {
+ first_x87_word: 10,
+ second_x87_word: 20,
+ third_word: 30,
+ }
+ }
+
+ fn one_formula(expression: &[u8]) -> FormulaTable {
+ let mut source = b"FUNCTION(test, ".to_vec();
+ source.extend_from_slice(expression);
+ source.extend_from_slice(b", )\n");
+ FormulaTable::parse(&source)
+ }
+
fn handler_two_input(
scalar_1: f32,
scalar_4: f32,
@@ -2625,12 +2806,265 @@ STRING( 8, ignored, ignored, ignored)\r\n";
}
#[test]
+ fn script_init_formula_variables_read_prior_writes_and_self() {
+ let varset = parse_varset(
+ b"VAR(DWORD, source, 0)\nVAR(DWORD, y, 0)\nVAR(DWORD, id, 0)\nVAR(float, target, 0)\nVAR(DWORD, integer, 0)\n",
+ )
+ .expect("numeric declarations");
+ let formulas = FormulaTable::parse(
+ b"FUNCTION(literal, 7.0, )\nFUNCTION(add, source + 2.0, )\nFUNCTION(subtract, target - source, )\nFUNCTION(self, target + 1.0, )\nFUNCTION(integer, 10.75 + 0.5, )\nFUNCTION(exponent, target + 1e-2, )\n",
+ );
+ let package = init_package(vec![
+ init_handler19(),
+ init_formula_assignment(3, 0),
+ init_formula_assignment(3, 1),
+ init_formula_assignment(3, 2),
+ init_formula_assignment(3, 3),
+ init_formula_assignment(4, 4),
+ init_formula_assignment(3, 5),
+ ]);
+
+ assert_eq!(
+ varset.resolve_init(&package, init_input(), Some(&formulas),),
+ Ok(ScriptInitResolution::Executed {
+ event_index: 0,
+ event_word: 0,
+ writes: vec![
+ ScriptInitWrite {
+ index: 0,
+ value: VarSetDefault::Dword(10),
+ },
+ ScriptInitWrite {
+ index: 1,
+ value: VarSetDefault::Dword(20),
+ },
+ ScriptInitWrite {
+ index: 2,
+ value: VarSetDefault::Dword(30),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(7.0_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(12.0_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(2.0_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(3.0_f32.to_bits()),
+ },
+ ScriptInitWrite {
+ index: 4,
+ value: VarSetDefault::Dword(11),
+ },
+ ScriptInitWrite {
+ index: 3,
+ value: VarSetDefault::FloatBits(3.01_f32.to_bits()),
+ },
+ ],
+ })
+ );
+ }
+
+ #[test]
+ fn script_init_formula_atoms_use_native_token_and_name_rules() {
+ let varset = parse_varset(
+ b"VAR(float, dup, 1)\nVAR(float, dup, 2)\nVAR(float, Name, 3)\nVAR(float, name, 4)\nVAR(float, 1e-2, 41)\nVAR(float, A, 8)\nVAR(float, A1, 9)\nVAR(DWORD, high, 4294967295)\nVAR(float, large, 16777216)\nVAR(float, target, 0)\nVAR(DWORD, integer, 0)\n",
+ )
+ .expect("numeric declarations");
+ let formulas = FormulaTable::parse(
+ b"FUNCTION(duplicate, dup + 0, )\nFUNCTION(upper, Name + 0, )\nFUNCTION(lower, name + 0, )\nFUNCTION(numeric_name, 1e-2 + 0, )\nFUNCTION(long_operator_prefix, A1 + 0, )\nFUNCTION(unsigned_dword, high + 0, )\nFUNCTION(negative_exponent, 5e-2 + 2e-2, )\nFUNCTION(positive_exponent, 1e+2 + 2e-2, )\nFUNCTION(binary32_rounding, 16777216 + 1, )\nFUNCTION(cancellation, target - 16777215, )\nFUNCTION(dword_target, 10.75 + 0.5, )\nFUNCTION(single_variable, large, )\nFUNCTION(negative_dword_wrap, 2 - 3, )\nFUNCTION(unsigned_dword_wrap, high + 0, )\n",
+ );
+ let package = init_package(
+ (0..14)
+ .map(|formula| {
+ init_formula_assignment(
+ if matches!(formula, 10 | 12 | 13) {
+ 10
+ } else {
+ 9
+ },
+ formula,
+ )
+ })
+ .collect(),
+ );
+
+ let resolved = varset
+ .resolve_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ )
+ .expect("resolve Init")
+ .clone();
+ assert_eq!(
+ resolved,
+ ScriptInitResolution::Executed {
+ event_index: 0,
+ event_word: 0,
+ writes: vec![
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(1.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(3.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(4.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(41.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(9.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(4_294_967_296.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits((0.05_f32 + 0.02_f32).to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits((100.0_f32 + 0.02_f32).to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(16_777_216.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(1.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 10,
+ value: VarSetDefault::Dword(11)
+ },
+ ScriptInitWrite {
+ index: 9,
+ value: VarSetDefault::FloatBits(16_777_216.0_f32.to_bits())
+ },
+ ScriptInitWrite {
+ index: 10,
+ value: VarSetDefault::Dword(u32::MAX)
+ },
+ ScriptInitWrite {
+ index: 10,
+ value: VarSetDefault::Dword(0)
+ },
+ ],
+ }
+ );
+ assert!(formulas.evaluate_numeric_literal(0).is_err());
+ }
+
+ #[test]
+ fn script_init_rejects_unsupported_formula_variable_forms() {
+ let varset = parse_varset(
+ b"VAR(float, target, 1)\nVAR(float, A, 2)\nVAR(float, A1, 3)\nVAR(float, ^name, 4)\n",
+ )
+ .expect("numeric declarations");
+ for expression in [
+ &b"target * 2"[..],
+ b"-1",
+ b"target + 1 + 2",
+ b"missing + 1",
+ b"A + 1",
+ b"^name + 1",
+ b"name\xc3\xa9 + 1",
+ ] {
+ let formulas = one_formula(expression);
+ let package = init_package(vec![init_formula_assignment(0, 0)]);
+ assert!(
+ matches!(
+ varset.resolve_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ ),
+ Ok(ScriptInitResolution::Unsupported {
+ error: ScriptInitResolveError::UnsupportedFormulaExpression {
+ index: 0,
+ ..
+ },
+ ..
+ })
+ ),
+ "accepted unsupported formula {:?}",
+ String::from_utf8_lossy(expression)
+ );
+ }
+
+ let mut inconsistent = varset;
+ inconsistent.declarations[0].default_value = VarSetDefault::Dword(1);
+ let formulas = one_formula(b"target + 1");
+ let package = init_package(vec![init_formula_assignment(0, 0)]);
+ assert!(matches!(
+ inconsistent.resolve_init(
+ &package,
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ ),
+ Ok(ScriptInitResolution::Unsupported {
+ error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, .. },
+ ..
+ })
+ ));
+
+ let mut inconsistent_target =
+ parse_varset(b"VAR(float, target, 1)\n").expect("numeric declaration");
+ inconsistent_target.declarations[0].default_value = VarSetDefault::Dword(1);
+ let formulas = one_formula(b"2.0 + 1");
+ assert!(matches!(
+ inconsistent_target.resolve_init(
+ &init_package(vec![init_formula_assignment(0, 0)]),
+ Handler19InitInput {
+ first_x87_word: 0,
+ second_x87_word: 0,
+ third_word: 0,
+ },
+ Some(&formulas),
+ ),
+ Ok(ScriptInitResolution::Unsupported {
+ error: ScriptInitResolveError::UnsupportedFormulaExpression { index: 0, .. },
+ ..
+ })
+ ));
+ }
+
+ #[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 varset = parse_varset(b"VAR(DWORD, target, 0)\nVAR(DWORD, integer, 0)\n")
+ .expect("numeric declaration");
let formulas =
- FormulaTable::parse(b"FUNCTION(unsupported, target + 2, )\nFUNCTION(literal, 7.5, )\n");
+ 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],
+ header_words: [u32::MAX, 1, u32::MAX, u32::MAX, 6, 0, formula_index],
references: Vec::new(),
};
let package = |formula_index| ScriptPackage {
@@ -2656,7 +3090,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
event_index: 0,
event_word: 0,
writes: vec![ScriptInitWrite {
- index: 0,
+ index: 1,
value: VarSetDefault::Dword(7),
}],
})
@@ -2668,7 +3102,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
instruction_index: 0,
error: ScriptInitResolveError::UnsupportedFormulaExpression {
index: 0,
- expression: b" target + 2".to_vec(),
+ expression: b" target * 2".to_vec(),
},
})
);
@@ -2682,7 +3116,7 @@ STRING( 8, ignored, ignored, ignored)\r\n";
instruction_index: 1,
error: ScriptInitResolveError::UnsupportedFormulaExpression {
index: 0,
- expression: b" target + 2".to_vec(),
+ expression: b" target * 2".to_vec(),
},
})
);
diff --git a/docs/appendices/knowledge-boundaries.md b/docs/appendices/knowledge-boundaries.md
index b313375..7fb6798 100644
--- a/docs/appendices/knowledge-boundaries.md
+++ b/docs/appendices/knowledge-boundaries.md
@@ -133,7 +133,7 @@ control word процесса.
Лицензированный AutoDemo по-прежнему загружается с raw TMA anchor-ами
`[(500.2857, 751.8217), (728.0, 449.0)]` и записями `[(500, 752, 0),
(728, 449, 1)]`. Rust теперь также читает optional `.fml` sibling для каждого
-script base и поддерживает numeric literal assignments в первом `Init`.
+script base и поддерживает узкий набор атомарных формул в первом `Init`.
Отсутствующий `.fml` не мешает пакету с одними `Handler(19)`; другие ошибки
чтения распространяются как VFS errors.
@@ -144,9 +144,11 @@ script base и поддерживает numeric literal assignments в первÐ
два доказанных действия: `Handler(19)` и formula assignment selector `-1` при
source `-1`, нулевой глубине условий и корректных индексах. У `Handler(19)`
control находится в `header_words[3]`, а `header_words[1]` — result-save
-reference; оба равны `0xffffffff` в поддержанной форме. У formula assignment
-`header_words[1]` — индекс целевой varset ячейки, `header_words[2]` — source,
-а индекс formula row занимает `header_words[6]`. Маппинг подтверждён
+reference; оба равны `0xffffffff` в поддержанной форме. В дисковом заголовке
+formula assignment `header_words[1]` — индекс целевой varset ячейки,
+`header_words[2]` — source, а индекс formula row занимает `header_words[6]`;
+индексы цели и формулы в native record находятся по смещениям `+0x08` и `+0x1c`
+соответственно. Маппинг подтверждён
`ai.dll!FUN_10011b20` (RVA `0x11b20`) и исполнителем `FUN_10012020` (RVA
`0x12020`), SHA-256 `c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`.
`Handler(19)` (`FUN_1000aa38`, RVA `0xaa38`) использует references `0..2`;
@@ -154,23 +156,58 @@ reference; оба равны `0xffffffff` в поддержанной форме
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` с
+строит числовые, переменные и операторные узлы, а evaluator `FUN_10015b30`
+(RVA `0x15b30`) в проверенных путях `+` и `-` считывает значения переменных и
+сохраняет результаты как `f32` при x87 control word `0x007f`. В GOG-сборке
+`ai.dll` с SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` поиск
+переменной через varset vtable slot `+0x10` ведёт на RVA `0x16ad0`: сравнение
+байтов точное, регистр учитывается, при повторах берётся первое объявление.
+Getter `FUN_10013190` (RVA `0x13190`) читает type `3` как `f32`, type `5` как
+беззнаковый `DWORD` и возвращает число через x87. Затем evaluator сохраняет
+его как `f32` до операции. Оператор `+`
+считает right+left, `-` — left-right; результат также сохраняется как `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-а.
+Граница Rust остаётся узкой: один числовой токен либо ASCII-имя переменной,
+или два таких атома с одним бинарным `+`/`-`. Имя проверяется раньше числового
+преобразования, сравнивается с учётом регистра и выбирает первое совпадение;
+публичный `FormulaTable::evaluate_numeric_literal` принимает только числовые
+литералы. Временная копия значений начинается со значений по умолчанию и
+обновляется после каждого успешного действия `Init`, поэтому следующая формула
+видит предыдущие записи. Нет
+поддержки неизвестных операторов, унарных форм, третьего атома, отсутствующих
+или не-ASCII имён и source-copy ветви `source != 0xffffffff`; native source-copy
+переносит всю запись varset. Preview сохраняет package, но не применяет частичные
+writes; полный load отклоняет новый мир и сохраняет прежние world/RNG. Это
+политика ограниченного Rust resolver-а, а не правило оригинального dispatcher-а.
+
+Ресурсные строки с переменными найдены в поздних событиях: `c1m3e.fml` row 19
+(`0.8 + fT`) вызывается из `Fort_Captured`, row 22
+(`dBuildingProtectHits - dT2`) — из `PBM_BUILDING_PROTECT_Start`, а `c1m4e.fml`
+row 0 (`fTemp + 0.00001`) — из `PBM_BUILDING_INF_CAPTURE_Start`. Декодирование
+этих `.scr` не показывает эти формулы в первом `Init`. Игнорируемая проверка
+читает настоящие `.fml` и `varset.var`, но вставляет строки в синтетический
+`Init`; она проверяет resolver, а не оригинальное исполнение. Одноразовый x86
+oracle на исходной GOG `ai.dll` с SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` проверил
+13 отдельных присваиваний: 9 контролируемых выражений и 4 вызова с настоящими
+строками `.fml` (`c1m3e` rows 19/22, `c1m4e` row 0 дважды подряд). В каждом векторе
+native harness записывал вычисленный `f32`, типизированное raw-слово и getter;
+scratch-проход через публичный Rust parser/resolver совпал со всеми 13
+векторами по `f32`-результату и raw-записи. Для native вызовов control word
+временно принудительно устанавливался в `0x007f`: 87 наблюдений сохранили
+`0x027f` на входе и после восстановления,
+а canary-проверки прошли. Менялись только varset-копии в памяти. Это
+подтверждает компиляцию формулы, локальное вычисление и typed writes для этих
+входов, но не запуск исходного поиска и обхода события `Init`, исполнение поздних
+событий или поведение живой миссии.
Лицензированный test загружает одну первую кампанийную миссию
`CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma` после literal writes; это не проход
@@ -184,6 +221,34 @@ core и `ResearchCandidateSet::refresh_selection`: последний получ
создаёт владельца живого контекста, не передаёт реальные AI candidates и не
выбирает результат Handler(43). Это не общий dispatcher и не загрузка игры.
+Начальный массив слота AI `+0x38` теперь восстановлен отдельно от этого
+исследовательского пути. В `iron3d.dll` SHA-256
+`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`
+`FUN_100392e0` (RVA `0x392e0`) вызывает слот для каждого значения счётчика
+`second_resource.tag`. В `ai.dll` SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` метод
+RVA `0x2010` передаёт `AI+0x83c` в `FUN_10002980` (RVA `0x2980`), где
+`FUN_100048f0` (RVA `0x48f0`) добавляет переданный DWORD. Вызывающая сторона
+повторно передаёт `0xffffffff`; эта функция не записывает значение обратно.
+Поэтому подтверждён только начальный массив sentinel-слов. Неизвестно, какие
+поздние обработчики читают эти ячейки и заменяет ли кто-либо их ID. В начальном
+массиве нет назначенных ID объектов; дальнейший смысл этих слов пока не
+установлен. Сам массив не является списком
+`BehaviorDistributorState.registered_objects` и не определяет владельца
+контекста исследований.
+
+Rust хранит этот снимок как `MissionAiStartupState` в состоянии загруженной
+миссии. Полная загрузка проходит по всем TMA-кланам, включая кланы без пакета
+сценария и без пути таблицы исследований; при предпросмотре список состояний AI
+остаётся пустым. Знаковое поле `second_resource.tag` читается по сырым битам как
+`u32`. Бюджет в 1 000 000 DWORD на миссию и выделение памяти с обработкой ошибки
+защищают только память Rust; это ограничение не приписывается оригинальному
+движку. Искусственные Rust-тесты проверяют обе формы TMA-клана, индекс клана,
+независимые массивы, пустой путь сценария, предпросмотр, чтение тега как
+беззнакового числа, лимит и откат при ошибке. Проверка на оригинальных ресурсах
+для `Single.01` подтверждает состояния обоих кланов (`9/9`) до выполнения
+`Init`; это не полный запуск миссии и не исполнение исходных DLL.
+
Для GOG-сборки также найден статический host call path в `Init`. В
`iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) одна ветвь сначала проходит записи
кланов и вызывает для них `FUN_100392e0`, затем вызывает `FUN_10039440` (RVA
@@ -877,22 +942,34 @@ arg1 следующего вызова. `AddNewObjectToGame` принимает
`FUN_10002a30` (RVA `0x2a30`, `RET 8`) записывает его в `inner+8`. TMA tag
`row+0x20` ограничивает число вызовов AI slot `+0x38`, но не задаёт владельца.
+Создание AI не фильтруется по наличию сценарного пути. В `iron3d.dll` SHA-256
+`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`
+`FUN_1005ff40` (RVA `0x5ff40`) проходит записи кланов и вызывает для каждой
+`FUN_100392e0` (RVA `0x392e0`); в `ai.dll` SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`
+`CreateSuperAI` (RVA `0xf710`) выделяет AI и вызывает конструктор по RVA
+`0x1000` без проверки пути сценария. Создание исследовательского контекста
+следует за успешным созданием AI и требует включённого флага создания
+исследований и непустого `second_resource.path` до первого NUL. Переданный в
+`descriptor[0]` индекс клана, а не `second_resource.tag`, задаёт владельца;
+тег остаётся счётчиком начальных вызовов AI slot `+0x38`.
+
В Rust эта граница представлена только загрузкой таблиц. Полный
`load_mission` требует явный `MissionStartupInput { queue_time_ms,
-research_creation_gate }`; gate не выводится из `MissionMode` или имени пути.
-При `true` загрузчик проходит по успешно разобранным пакетам сценариев, берёт
-реальный `second_resource.path` соответствующего TMA-клана до первого NUL,
-отклоняет не-ASCII путь, затем сохраняет отдельный `ResearchTrfDocument` с
-`clan_index` и нормализованным путём. Пустая строка, выключенный gate или
-отсутствие пакета сценариев пропускают чтение. Повтор одного пути разными
-владельцами приводит к отдельным буферам строк TRF0. Обычные тесты на
-искусственных данных проверяют владельцев, повторное декодирование, пропуски,
-ошибки и откат; игнорируемая проверка оригинальных ресурсов связывает реальные
-`Campaign.00/Mission.01` пары путей с индексами владельцев. Это не загружает
-полный исследовательский контекст оригинала.
-Успешно разобранный пакет сценариев здесь обозначает только границу текущей
-настройки Rust, а не совпадение с полным нативным жизненным циклом
-`CreateSuperAI`.
+research_creation_gate }`; этот флаг не выводится из `MissionMode` или имени
+пути. Если флаг равен `true`, загрузчик проходит по существующим
+`MissionAiStartupState` всех TMA-кланов, в том числе без сценарного пакета,
+читает их `second_resource.path` до первого NUL и отклоняет не-ASCII путь.
+Пустая строка пропускается; выключенный флаг и статический preview не читают
+TRF. На каждого
+владельца сохраняется отдельный `ResearchTrfDocument` с нормализованным путём,
+даже когда разные кланы указывают на один файл. Обычные тесты на искусственных
+данных проверяют владение у кланов со сценариями и без них, повторное
+декодирование, пропуски, ошибки и откат; игнорируемая проверка оригинальных
+ресурсов связывает реальные `Campaign.00/Mission.01` пары путей с индексами владельцев.
+Rust пока сохраняет только декодированные данные и связь с владельцем: он не
+создаёт полный исследовательский контекст, не строит AI-каталог и не исполняет
+`Handler(43)`.
Нативный metadata loader нельзя считать простым копированием таблицы. В GOG
`Behavior.dll` (SHA-256
diff --git a/docs/appendices/script-vm.md b/docs/appendices/script-vm.md
index 6e48445..51dd429 100644
--- a/docs/appendices/script-vm.md
+++ b/docs/appendices/script-vm.md
@@ -22,8 +22,8 @@ symbol/formula oracle; `varset.var` задаёт `VAR(...)`/`STRING(...)` defaul
`fparkan-script::parse_varset` уже читает подтверждённые numeric
`VAR(float|DWORD, name, default)` declarations byte-safe (comments остаются
opaque, поэтому legacy non-UTF-8 text не ломает загрузку); `STRING(...)` и
-general `FUNCTION(...)` evaluation остаётся вне numeric contract, но принятые
-строки сохраняются для доказанных literal assignments первого `Init`;
+general `FUNCTION(...)` evaluation остаются вне восстановленного контракта,
+но принятые строки сохраняются для ограниченного поднабора формул первого `Init`;
GOG `MISSIONS/SCRIPTS/varset.var` даёт через него ровно 231 declaration:
31 `float` и 200 `DWORD` (от `f0` до `fY`);
loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при
@@ -424,10 +424,13 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D
Ограниченный Rust-исполнитель `VarSet::resolve_init` ищет первое событие
`Init` и проходит его инструкции по порядку. Он возвращает записи для
-поддержанного `Handler(19)` и числовых literal assignments; прочая форма
-останавливает preview на `Unsupported`. Runtime применяет упорядоченные записи
-к отдельной копии varset выбранного клана лишь после успешного прохода всего
-события. Отсутствующий `Init` даёт
+поддержанного `Handler(19)` и формул с одним числовым или переменным атомом либо
+с двумя такими атомами, соединёнными одним бинарным `+` или `-`; прочая форма
+останавливает preview на `Unsupported`. Runtime начинает вычисление с копии
+defaults и записывает в неё каждый успешно разрешённый шаг, поэтому поздняя
+формула видит прежние записи этого события. Упорядоченные записи попадают в
+состояние клана только после успешного прохода всего `Init`. Отсутствующий
+`Init` даёт
статус `Missing` и не создаёт записей инициализации; пустой первый `Init` даёт
`Executed` без записей. Если форма инструкции пока не поддержана, package
остаётся доступен в preview режиме со статусом `Unsupported`, а default-значения
@@ -437,7 +440,7 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D
Это правила ограниченного Rust-исполнителя, а не обнаруженное поведение
оригинального dispatcher-а.
-#### Числовые литералы `.fml` в `Init`
+#### Литералы и простые формулы `.fml` в `Init`
В той же сборке `ai.dll` (`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`)
загрузчик `FUN_10001000` открывает `.fml` рядом с базой каждого `.scr`.
@@ -455,16 +458,35 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D
остаётся ошибкой 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`.
+formula assignment. В дисковом заголовке `.scr` `header_words[1]` — индекс
+целевой varset-ячейки, а `header_words[6]` — индекс formula row; соответствующие
+поля native record находятся по смещениям `+0x08` и `+0x1c` соответственно.
+`FUN_100123b0` (RVA `0x123b0`) проверяет индекс строки. Компилятор
+`FUN_100152a0` (RVA `0x152a0`) создаёт
+числовые узлы, узлы переменных и узлы операторов; числовой prefix проходит через
+`FUN_10020af0` (RVA `0x20af0`) как `double`, после чего сохраняется в узле как
+`float`. Для переменной компилятор вызывает vtable slot `+0x10` таблицы varset;
+в проверенной GOG-сборке `ai.dll` этот вызов ведёт к поиску по RVA `0x16ad0`.
+Поиск сравнивает байты имени с учётом регистра и возвращает первое совпадение.
+`FUN_10015b30` (RVA `0x15b30`) читает числовой узел как `f32`, а переменную —
+через `FUN_10013190` (RVA `0x13190`): type `3` даёт `float`, type `5` —
+беззнаковый `DWORD`. Getter возвращает число через x87; evaluator сохраняет
+его как `f32` перед операцией. Оператор `+` складывает правый
+и левый операнды, а `-` вычисляет левый минус правый; после узла результат
+сохраняется как `f32`. `FUN_10013650` передаёт итог типизированной ячейке через
+`FUN_10012c00` (RVA `0x12c00`).
+
+Rust в первом `Init` принимает один числовой токен или имя ASCII-переменной,
+а также ровно два таких атома с одним бинарным `+` либо `-`. Например,
+`source + 2.0` и `target - source` читают значения из defaults или из предыдущих
+успешных записей события. Имя ищется раньше числового преобразования, поэтому
+объявление с именем `1e-2` перекрывает такую числовую запись; сравнение имён
+чувствительно к регистру, а повтор объявлений выбирает первое. `DWORD` читается
+как беззнаковое число и округляется до `f32` до операции. Публичный
+`FormulaTable::evaluate_numeric_literal` по-прежнему принимает только литералы.
+Ведущие unary `+`/`-`, `*`, другие операторы, третий атом, отсутствующие или
+не-ASCII имена и зарезервированные одиночные операторы остаются `Unsupported`;
+так же не поддержана ветвь копирования `source != 0xffffffff`.
Проверенный native tokenizer отличается от обычного строгого decimal parser:
`1e-2`, `1.`, `1e`, `1e+`, `1ee2` и `1e2.3` были приняты как один literal node
@@ -477,8 +499,8 @@ digit-starting числовым токеном с необязательным Ñ
исходные `f32` bits; `DWORD` (tag `5`) проходит через `__ftol` (RVA `0x1df70`)
с усечением к нулю и записывает младший DWORD. Поэтому literal `0.8` остаётся
`0x3f4ccccd` в `float`, но записывается как `0` в `DWORD`; `100.0` даёт raw
-DWORD `100`. Неизвестные выражения не заменяются нулём, а запись всего первого
-`Init` применяется только после успешного прохода.
+DWORD `100`. Неизвестные выражения не заменяются нулём, а записи первого `Init`
+публикуются только после успешного прохода.
Native x86 oracle на GOG `ai.dll` SHA-256
`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc` загрузил
@@ -500,6 +522,27 @@ Native x86 oracle на GOG `ai.dll` SHA-256
| 7 | `dMaxPlant` (`DWORD`) | `400 → 400` |
| 8 | `d7` (`DWORD`) | `2 → 2` |
+Отдельный одноразовый x86 oracle на той же GOG `ai.dll` проверил ещё 13
+выражений: 9 строк контролируемой `controlled.fml`, строки 19 и 22 настоящей
+`c1m3e.fml` и два последовательных вызова строки 0 настоящей `c1m4e.fml`.
+Native harness записывал `f32`-результат evaluator-а, типизированную raw-запись
+и getter после записи. В scratch-проверке те же входы прошли через публичные
+`parse_varset`, `FormulaTable::parse` и `VarSet::resolve_init`; результат
+evaluator-а и raw-слово Rust совпали с native во всех 13 случаях. Среди
+проверенных границ: `2 - 3` записал в `DWORD` `0xffffffff`; чтение исходного
+`DWORD 0xffffffff` дало `f32 0x4f800000`, а последующее `+ 0` записало в
+`DWORD` ноль. Для настоящих строк `c1m3e` получены `fTemp = 0x40333333` и
+`dTemp3 = 0`; два последовательных вызова строки 0 `c1m4e` дали
+`fTemp = 0x4033335d`, затем `0x40333387`.
+
+Oracle принудительно устанавливал x87 control word `0x007f` на время каждого
+проверяемого вызова: все 87 наблюдений показали входное и восстановленное
+значение `0x027f`, активное и выходное — `0x007f`; canary-проверки прошли.
+Менялись только varset-копии в памяти процесса. Эта выборка подтверждает
+компиляцию, вычисление и типизированные записи ровно для перечисленных входов.
+Она не запускала исходный поиск и обход события `Init` и не исполняла поздние
+события или миссию; выборка не доказывает полную грамматику evaluator-а.
+
Rust сохраняет всю таблицу строк, но вычисляет только формулу, на которую
ссылается поддержанная запись. Лицензированный runtime-тест подтверждает
полную загрузку кампании Mission.01 после этих присваиваний; у обычной
@@ -511,6 +554,17 @@ Rust сохраняет всю таблицу строк, но вычисляеÑ
не перечисляют AI candidates и не выполняют этот handler. Это не подтверждение
запуска самой игры или полной VM.
+В других строках тех же ресурсов встречаются `0.8 + fT` (`c1m3e.fml`, row 19),
+`dBuildingProtectHits - dT2` (row 22) и `fTemp + 0.00001` (`c1m4e.fml`, row 0).
+Декодированные `c1m3e.scr` и `c1m4e.scr` ссылаются на них из более поздних
+событий `Fort_Captured`, `PBM_BUILDING_PROTECT_Start` и
+`PBM_BUILDING_INF_CAPTURE_Start`, а не из их `Init`. Игнорируемый Rust-тест
+подаёт эти сохранённые строки в изменённый синтетический `Init` и использует
+настоящий общий `varset.var`; это проверка resolver-а, а не утверждение о
+исполнении таких выражений в исходном `Init`. Отдельный native oracle и
+сравнение с Rust выше проверяют эти строки при явно заданных исходных ячейках,
+но не подтверждают их исполнение в оригинальном сценарии или в живой игре.
+
### Первый вызов Behavior и сценарного `Init`
В той же GOG-сборке `Behavior.dll` (SHA-256
@@ -760,8 +814,9 @@ tie-векторов подтверждает реакцию `FISTP`, но не
два слова и третье исходное слово, выдаёт три типизированные записи и отклоняет
отсутствующие, выходящие за диапазон или не-`DWORD` цели. Общий для первого
события метод `VarSet::resolve_init` добавляет этот `Handler(19)` и формулу из
-одного numeric literal; остальные формы инструкции и содержимое `.scr`
-сохраняются reader-ом, но остаются вне этого исполнения.
+одного числового или переменного атома либо двух таких атомов с одним бинарным
+`+` или `-`. Остальные формы инструкции и содержимое `.scr` сохраняются
+reader-ом, но остаются вне этого исполнения.
### Начальная группировка подготовленных Control-дескрипторов
diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md
index 6684b13..3a4e51c 100644
--- a/docs/tomes/06-behavior.md
+++ b/docs/tomes/06-behavior.md
@@ -169,8 +169,10 @@ handler лишь инициирует execution context (`+0x50 = 1`); его ga
показывает предупреждение для чисел выше `10000`, но не отменяет создание AI.
Rust исполняет подтверждённый поднабор первого `Init`: действие `Handler(19)`
-и числовые literal assignments, описанные ниже. Поддержанные записи применяются
-к состоянию лишь после успешного прохода всего первого `Init`.
+и формулы из одного атома либо двух атомов с бинарным `+` или `-`. Здесь атом —
+число или имя переменной. Поддержанные шаги сразу обновляют временную копию
+varset, поэтому следующая формула читает уже записанные значения. К состоянию
+клана записи попадают лишь после успешного прохода всего первого `Init`.
Если встретилась неподдержанная форма, preview сохраняет пакет, сообщает
`Unsupported` и оставляет значения по умолчанию; при полной загрузке та же форма
становится ошибкой. Это политика Rust для ограниченного исполнителя, а не правило
@@ -186,15 +188,54 @@ Rust исполняет подтверждённый поднабор перво
нули в `dT8` и `dT9`.
Клан 1 той же миссии использует первые девять строк `scream.fml`: восемь
проверенных значений попадают в `DWORD` с усечением дробной части, а `0.8`
-сохраняется как число `float`. Пока Rust вычисляет только числовой литерал,
-начинающийся с цифры; операторы, ссылки на переменные и другие формы формул
-остаются явными ошибками. В тесте полной загрузки миссия `Mission.01`
-проходит этот `Init`; `Single.01` останавливается на следующем неподдержанном
-`Handler(43)` в clan 1, записи 10. Никакое значение не подставляется вместо
-этого действия.
+сохраняется как число `float`. Например, если `source` равен `10`, выражение
+`source + 2.0` записывает `12`; следующее `target - source` читает новое значение
+`target` и получает `2`. Каждая операция работает с `f32`: значение ячейки
+`DWORD` сначала читается как беззнаковое число и переводится в `f32`, а результат
+сохраняет тип целевой ячейки. Поддерживаются только один или два атома и один
+бинарный `+` либо `-`; произвольная грамматика формул и остальная VM не
+восстановлены. В отдельной точечной проверке вычисления и записи оригинальной
+DLL и Rust совпали для 13 выбранных формул. Эта сверка проверяет только
+эти значения и не запускает исходную миссию. Тест полной загрузки подтверждает,
+что `Mission.01` проходит этот `Init`; у `Single.01` следующий неподдержанный
+`Handler(43)` встречается в clan 1, записи 10. Никакое значение не подставляется
+вместо этого действия.
### Первый запуск сценария
+Перед настройкой первого шага каждый AI получает начальный массив DWORD. В
+`iron3d.dll` SHA-256
+`d1dc4ea8535e2069b0a05c9e8be2724dd438ba44cca7a83007d8b2e50f9e35fa`
+`FUN_100392e0` (RVA `0x392e0`) повторяет вызов слота AI `+0x38` столько раз,
+сколько задаёт `second_resource.tag` этой строки TMA. Этот слот ведёт в
+`ai.dll` SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`:
+метод RVA `0x2010` передаёт коллекцию при `AI+0x83c` в `FUN_10002980`
+(RVA `0x2980`), а она добавляет один DWORD через `FUN_100048f0` (RVA
+`0x48f0`). Каждый вызов копирует `0xffffffff`; входное слово не меняется.
+Поэтому тег `3` даёт `[0xffffffff, 0xffffffff, 0xffffffff]`. Это число
+начальных ячеек, а не назначение трёх ID.
+
+Изолированная x86-проверка вызовом оригинальной `ai.dll` подтвердила пары
+«число слотов/ёмкость» `0/0`, `1/17`, `17/17` и `18/34`. Все добавленные слова
+равны `0xffffffff`, а входное значение, контрольные поля, память вне заголовка
+массива и указатель стека сохраняются. Это подтверждает добавление начального
+слова, но не работу последующих обработчиков.
+
+Rust хранит такой снимок в отдельном `MissionAiStartupState` для каждого TMA-клана,
+а не в пакете сценария. При полной загрузке (`Full`) создаётся запись для каждого
+клана, в том числе если у него нет пути сценария или пути таблицы исследований. Счётчик
+берётся из сырых битов `second_resource.tag` как `u32` у обеих форм записи —
+`Standard` и `Spatial`; нулевой счётчик даёт существующее состояние с пустым
+списком. Статический режим предпросмотра не создаёт состояний AI.
+
+Загрузка отклоняет суммарный объём свыше 1 000 000 DWORD и резервирует память
+проверяемой операцией; это защита Rust от чрезмерного выделения памяти, а не
+ограничение оригинальной игры. Rust сохраняет начальные слова, но не
+восстанавливает работу последующих обработчиков и не назначает этим словам ID
+объектов. Этот массив также не является списком
+`BehaviorDistributorState.registered_objects`.
+
До регулярной работы Behavior настраивает расписание своего первого шага. Он
берёт текущее 32-битное время игровой очереди и назначает проверку через три
интервала по 64 мс, добавляя небольшую случайную задержку от 0 до 63 мс. Эта
@@ -213,7 +254,7 @@ SuperAI для записей кланов, затем обновляет Behavi
всё равно устанавливается. В полной загрузке Rust вызывающая сторона передаёт
значение времени очереди; после регистрации объектов загрузчик последовательно
подготавливает каждый сценарный пакет и выполняет восстановленные действия
-`Handler(19)` и числовые literal assignments из первого `Init`. Статический
+`Handler(19)` и поддержанные формульные присваивания из первого `Init`. Статический
просмотр не запускает Behavior, но может сохранить эти записи `Init` в сценарном
varset.
@@ -546,8 +587,14 @@ let cost = calc_full_research_cost(&candidate, &table)?;
штатный Handler(43) и запуск миссии здесь не проверялись.
Владелец начальной записи исследования — индекс текущего прохода по кланам.
-Движок сохраняет его в `descriptor[0]`, а затем переносит в контекст, чтобы
-связать запрос с AI-данными того же клана.
+В GOG-сборке `iron3d.dll!FUN_1005ff40` (RVA `0x5ff40`) проходит записи кланов
+и вызывает для каждой `FUN_100392e0` (RVA `0x392e0`), которая создаёт AI;
+`ai.dll!CreateSuperAI` (RVA `0xf710`) выделяет объект и вызывает конструктор
+по RVA `0x1000`, не проверяя наличие сценарного пути. Для исследовательского
+пути нужны отдельный флаг включения и непустой `second_resource.path`; наличие
+сценарного пакета условием не является. Индекс клана сохраняется в
+`descriptor[0]` и переносится в контекст, чтобы связать запрос с AI-данными
+того же клана.
Это значение отдельно от аргумента 1 `AddNewObjectToGame`, берущегося из
`GetNetPlayerNum`, и от TMA-тега `second_resource`, задающего число вызовов
@@ -557,12 +604,14 @@ Handler(43) и штатный запуск миссии остаются непÑ
Полный Rust-загрузчик принимает явный `MissionStartupInput`: время очереди
и отдельный `research_creation_gate`. Режим миссии и имя файла этот
-флаг не заменяют. При включённом флаге таблица загружается только для клана,
-чей сценарный пакет уже прочитан и успешно разобран; путь берётся из второго
-ресурса этого клана как C-строка. Пустая C-строка пропускается, а не-ASCII
-байты дают явную ошибку. Клан индексируется по порядку в TMA, тег ресурса на
-владение не влияет. Если несколько кланов указывают на один файл, каждый
-получает отдельно декодированную таблицу с собственными строками TRF0.
+флаг не заменяют. При включённом флаге загрузчик использует уже имеющееся
+`MissionAiStartupState` каждого TMA-клана, даже если у него нет сценарного
+пакета; путь берётся из второго ресурса как C-строка до первого NUL. Пустой
+путь и выключенный флаг пропускают чтение, а не-ASCII байты дают явную ошибку.
+Предпросмотр тоже не читает TRF. Клан индексируется по порядку в TMA, а тег
+`second_resource` задаёт число слотов AI, не владельца. Если несколько кланов
+указывают на один файл, каждый получает отдельно декодированную таблицу со
+своими строками TRF0.
```rust
MissionStartupInput {
@@ -571,9 +620,10 @@ MissionStartupInput {
}
```
-Это сохраняет исходные таблицы и их связь с владельцами для следующих этапов.
-Оно ещё не создаёт полный исследовательский контекст оригинала и не запускает
-`Handler(43)`; эта обработка пока остаётся явно неподдержанной.
+Это сохраняет декодированные таблицы и связь с владельцами для следующих
+этапов. Оно ещё не создаёт полный исследовательский контекст оригинала, не
+строит AI-каталог и не запускает `Handler(43)`; эти части пока не
+восстановлены.
Отдельная frozen-матрица непосредственно вызвала chooser GOG `ai.dll` SHA-256
`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`: функция