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_script::{
Handler19DwordWrite, Handler19InitInput, Handler19InitResolution, Handler19ResolveError,
ScriptInstruction, ScriptPackage, VarSet, VarSetDefault, VmHostCallbackCommand,
FormulaTable, Handler19InitInput, ScriptInitResolution, ScriptInitResolveError,
ScriptInitWrite, ScriptInstruction, ScriptPackage, VarSet, VarSetDefault,
VmHostCallbackCommand,
};
use fparkan_terrain::SurfaceQuery;
use fparkan_vfs::{Vfs, VfsError};
@@ -657,9 +658,11 @@ pub struct MissionScriptBundle {
pub base_path: String,
/// Resolved compiled `.scr` path.
pub script_path: String,
/// Losslessly decoded compiled package; only proven `Handler(19)` Init
/// writes are resolved and retained by runtime.
/// Losslessly decoded compiled package; only the recovered first-`Init`
/// subset is resolved and retained by runtime.
pub package: ScriptPackage,
/// Optional formula rows loaded from this bundle's sibling `.fml` file.
pub formula_table: Option<FormulaTable>,
/// Outcome of the restricted first-`Init` resolution for this bundle.
pub init_execution: MissionScriptInitExecution,
/// Recovered per-AI Behavior scheduler and total state.
@@ -725,11 +728,12 @@ fn schedule_behavior_distributor_tick(
Some(behavior_elapsed_seconds(elapsed_ms))
}
/// Numeric script defaults selected for the mission's first script bundle.
/// Numeric script declarations selected for the mission's first script bundle.
///
/// GOG `ai.dll` first tries `<bundle-base>.var`, then falls back to the shared
/// `MISSIONS/SCRIPTS/varset.var`. This stores the successfully selected source
/// and supplies the typed targets for the proven `Handler(19)` Init writes.
/// and supplies typed targets for recovered `Handler(19)` and numeric formula
/// assignments in the first `Init` event.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionScriptVarSet {
/// Resolved `.var` path selected by the original fallback order.
@@ -740,11 +744,7 @@ pub struct MissionScriptVarSet {
pub declarations: VarSet,
}
/// One resolved, corpus-proven `Init` result for a mission clan.
///
/// This is intentionally limited to `Handler(19)`: an `Init` event's other
/// selectors are retained in [`MissionScriptBundle::package`] but are not
/// guessed as executable behavior.
/// One resolved subset of the first `Init` event for a mission clan.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionScriptInitState {
/// TMA clan index that owns the `SuperAI` instance and varset.
@@ -753,8 +753,8 @@ pub struct MissionScriptInitState {
pub event_index: usize,
/// Raw event word preserved from the compiled `Init` event.
pub event_word: u32,
/// Exact three DWORD writes made by each proven `Handler(19)` instruction.
pub writes: Vec<Handler19DwordWrite>,
/// Ordered numeric writes made by the supported `Init` instructions.
pub writes: Vec<ScriptInitWrite>,
}
/// Execution status of the first event whose case-sensitive C-string name is `Init`.
@@ -768,14 +768,14 @@ pub enum MissionScriptInitExecution {
event_index: usize,
},
/// A preview retained the decoded package but stopped before the first
/// instruction outside the recovered `Handler(19)` action subset.
/// instruction outside the recovered numeric `Init` subset.
Unsupported {
/// Zero-based event table index selected by native lookup.
event_index: usize,
/// Zero-based instruction index where restricted resolution stopped.
instruction_index: usize,
/// Typed selector, control, or result-save shape that is not modeled.
error: Handler19ResolveError,
/// Typed selector, operand, or expression shape that is not modeled.
error: ScriptInitResolveError,
},
}
@@ -918,7 +918,7 @@ pub struct LoadedMission {
pub script_event_count: usize,
/// Numeric script defaults loaded through the original bundle/fallback rule.
pub script_varset_declaration_count: usize,
/// `Handler(19)` Init results resolved for mission clans.
/// Writes resolved for the first mission-clan `Init` events.
pub script_init_state_count: usize,
/// Instantiated per-clan varsets whose Init writes have been applied.
pub script_varset_state_count: usize,
@@ -997,7 +997,7 @@ pub enum EngineError {
/// Parse diagnostic.
message: String,
},
/// A recovered `Handler(19)` Init input or target was not representable.
/// A recovered `Init` input or write target was not representable.
ScriptInit {
/// Owning TMA clan index.
clan_index: usize,
@@ -1015,7 +1015,7 @@ pub enum EngineError {
/// Zero-based instruction index within that event.
instruction_index: usize,
/// Typed unsupported instruction shape.
source: Handler19ResolveError,
source: ScriptInitResolveError,
},
/// A recovered script handler could not resolve against runtime state.
ScriptRuntime {
@@ -1831,10 +1831,11 @@ pub fn loaded_mission_script_varset(engine: &Engine) -> Option<&MissionScriptVar
.and_then(|state| state.script_varset.as_ref())
}
/// Returns the proven script `Init` writes resolved for the loaded mission.
/// Returns the recovered script `Init` writes resolved for the loaded mission.
///
/// The list has one entry for each clan whose `Init` event contains recovered
/// `Handler(19)` instructions. It is not a general script event executor.
/// The list has one entry for each clan whose first `Init` event contains a
/// supported `Handler(19)` instruction or numeric FML literal assignment. It
/// is not a general script event executor.
#[must_use]
pub fn loaded_mission_script_init_states(engine: &Engine) -> Option<&[MissionScriptInitState]> {
engine
@@ -6335,6 +6336,37 @@ fn normalize_engine_path(role: &'static str, value: &str) -> Result<NormalizedPa
})
}
fn script_bundle_stem(base_path: &str) -> &str {
let suffix_start = base_path.len().saturating_sub(4);
if base_path
.get(suffix_start..)
.is_some_and(|suffix| suffix.eq_ignore_ascii_case(".scr"))
{
&base_path[..suffix_start]
} else {
base_path
}
}
fn script_sibling_path(base_path: &str, extension: &str) -> String {
format!("{}.{extension}", script_bundle_stem(base_path))
}
fn load_script_formula_table(
vfs: &Arc<dyn Vfs>,
base_path: &str,
) -> Result<Option<FormulaTable>, EngineError> {
let path = normalize_engine_path("mission formula", &script_sibling_path(base_path, "fml"))?;
match vfs.read(&path) {
Ok(bytes) => Ok(Some(FormulaTable::parse(&bytes))),
Err(VfsError::NotFound(_)) => Ok(None),
Err(source) => Err(EngineError::Vfs {
path: path.as_str().to_string(),
source,
}),
}
}
fn load_mission_scripts(
vfs: &Arc<dyn Vfs>,
mission: &MissionDocument,
@@ -6356,6 +6388,7 @@ fn load_mission_scripts(
path: normalized.as_str().to_string(),
message: source.to_string(),
})?;
let formula_table = load_script_formula_table(vfs, &base_path)?;
if first_base_path.is_none() {
first_base_path = Some(base_path.clone());
}
@@ -6364,6 +6397,7 @@ fn load_mission_scripts(
base_path,
script_path: normalized.as_str().to_string(),
package,
formula_table,
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
});
@@ -6375,7 +6409,7 @@ fn load_mission_scripts(
let (init_states, varset_states) = if asset_scope == MissionAssetScope::Full {
(Vec::new(), Vec::new())
} else if let Some(varset) = &varset {
let init_states = resolve_handler19_init_states(
let init_states = resolve_init_states(
&mission
.clans
.iter()
@@ -6420,7 +6454,7 @@ fn start_mission_scripts(
));
if let Some(varset) = scripts.varset.as_ref() {
init_states.extend(resolve_handler19_init_states(
init_states.extend(resolve_init_states(
&clan_anchors,
std::slice::from_mut(bundle),
varset,
@@ -6462,12 +6496,12 @@ fn materialize_script_varset_states(
return Err(EngineError::ScriptInit {
clan_index: bundle.clan_index,
message: format!(
"Handler(19) write index {} is outside the instantiated varset",
"Init write index {} is outside the instantiated varset",
write.index
),
});
};
*value = VarSetDefault::Dword(write.value);
*value = write.value;
}
}
Ok(MissionScriptVarSetState {
@@ -6478,7 +6512,7 @@ fn materialize_script_varset_states(
.collect()
}
fn resolve_handler19_init_states(
fn resolve_init_states(
clan_anchors: &[[f32; 2]],
bundles: &mut [MissionScriptBundle],
varset: &MissionScriptVarSet,
@@ -6511,15 +6545,15 @@ fn resolve_handler19_init_states(
};
match varset
.declarations
.resolve_handler19_init(&bundle.package, input)
.resolve_init(&bundle.package, input, bundle.formula_table.as_ref())
.map_err(|source| EngineError::ScriptInit {
clan_index: bundle.clan_index,
message: source.to_string(),
})? {
Handler19InitResolution::Missing => {
ScriptInitResolution::Missing => {
bundle.init_execution = MissionScriptInitExecution::Missing;
}
Handler19InitResolution::Executed {
ScriptInitResolution::Executed {
event_index,
event_word,
writes,
@@ -6534,7 +6568,7 @@ fn resolve_handler19_init_states(
});
}
}
Handler19InitResolution::Unsupported {
ScriptInitResolution::Unsupported {
event_index,
instruction_index,
error,
@@ -6545,7 +6579,7 @@ fn resolve_handler19_init_states(
error,
};
}
Handler19InitResolution::Unsupported {
ScriptInitResolution::Unsupported {
event_index,
instruction_index,
error,
@@ -9626,6 +9660,7 @@ mod tests {
trailing_bytes: Vec::new(),
raw: Arc::from(&b""[..]),
},
formula_table: None,
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
};
@@ -9642,7 +9677,7 @@ mod tests {
assert!(matches!(
error,
EngineError::ScriptInitUnsupported {
source: Handler19ResolveError::UnsupportedSelector {
source: ScriptInitResolveError::UnsupportedSelector {
found: fparkan_script::ScriptDispatchSelector::Handler(18),
},
..
@@ -12212,6 +12247,204 @@ mod tests {
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]
fn script_varset_prefers_bundle_adjacent_file_then_uses_shared_fallback() {
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]
fn handler_nineteen_init_executes_from_clan_anchor_for_each_bundle() {
let declarations = fparkan_script::parse_varset(
@@ -12290,11 +12544,12 @@ mod tests {
trailing_bytes: Vec::new(),
raw: Arc::from(&b""[..]),
},
formula_table: None,
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
}];
let states = resolve_handler19_init_states(
let states = resolve_init_states(
&[[500.0, 752.0], [728.0, 449.0]],
&mut bundles,
&varset,
@@ -12308,15 +12563,18 @@ mod tests {
event_index: 0,
event_word: 0x1234_5678,
writes: vec![
Handler19DwordWrite {
ScriptInitWrite {
index: 0,
value: 728
value: VarSetDefault::Dword(728),
},
Handler19DwordWrite {
ScriptInitWrite {
index: 1,
value: 449
value: VarSetDefault::Dword(449),
},
ScriptInitWrite {
index: 2,
value: VarSetDefault::Dword(1),
},
Handler19DwordWrite { index: 2, value: 1 },
],
}]
);
@@ -12387,12 +12645,13 @@ mod tests {
base_path: "MISSIONS/SCRIPTS/default".to_string(),
script_path: "MISSIONS/SCRIPTS/default.scr".to_string(),
package: package.clone(),
formula_table: None,
init_execution: MissionScriptInitExecution::Missing,
behavior_warm_start: None,
};
let anchors = [[500.0, 752.0]];
let mut preview_bundles = vec![bundle()];
let preview_states = resolve_handler19_init_states(
let preview_states = resolve_init_states(
&anchors,
&mut preview_bundles,
&varset,
@@ -12405,7 +12664,7 @@ mod tests {
MissionScriptInitExecution::Unsupported {
event_index: 0,
instruction_index: 1,
error: Handler19ResolveError::UnsupportedSelector {
error: ScriptInitResolveError::UnsupportedSelector {
found: ScriptDispatchSelector::Handler(18),
},
}
@@ -12426,7 +12685,7 @@ mod tests {
let mut full_bundles = vec![bundle()];
assert!(matches!(
resolve_handler19_init_states(
resolve_init_states(
&anchors,
&mut full_bundles,
&varset,
@@ -12436,7 +12695,7 @@ mod tests {
clan_index: 0,
event_index: 0,
instruction_index: 1,
source: Handler19ResolveError::UnsupportedSelector {
source: ScriptInitResolveError::UnsupportedSelector {
found: ScriptDispatchSelector::Handler(18),
},
..
@@ -12452,7 +12711,7 @@ mod tests {
..bundle()
}];
assert!(matches!(
resolve_handler19_init_states(
resolve_init_states(
&anchors,
&mut malformed_bundle,
&varset,
@@ -13731,16 +13990,17 @@ mod tests {
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_campaign_preview_keeps_unsupported_init_without_partial_writes() {
fn licensed_campaign_full_load_applies_literals_then_single_handler43_rolls_back() {
let root = licensed_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
let mut engine = create(EngineServices::new(vfs)).expect("engine");
let mut engine = create(EngineServices::new(vfs.clone())).expect("engine");
let campaign_request = || MissionRequest {
key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
mode: MissionMode::SingleMission,
};
let loaded = load_mission_static_preview_roots(
&mut engine,
MissionRequest {
key: "MISSIONS/CAMPAIGN/CAMPAIGN.00/Mission.01/data.tma".to_string(),
mode: MissionMode::SingleMission,
},
campaign_request(),
NonZeroUsize::new(1).expect("non-zero root count"),
)
.expect("load licensed campaign preview");
@@ -13759,42 +14019,252 @@ mod tests {
.iter()
.all(|bundle| bundle.behavior_warm_start.is_none()));
assert_eq!(engine.behavior_rng_state, INITIAL_BEHAVIOR_RNG_STATE);
let unsupported = bundles
let bundle = bundles
.iter()
.find(|bundle| {
matches!(
&bundle.init_execution,
MissionScriptInitExecution::Unsupported { .. }
)
})
.expect("campaign package with an unsupported Init instruction");
.find(|bundle| bundle.clan_index == 0)
.expect("first campaign script bundle");
assert_eq!(
&unsupported.init_execution,
&MissionScriptInitExecution::Unsupported {
event_index: 0,
instruction_index: 1,
error: Handler19ResolveError::UnsupportedSelector {
found: fparkan_script::ScriptDispatchSelector::Sentinel,
},
}
bundle.base_path.replace('\\', "/"),
"MISSIONS/SCRIPTS/tut1_pl2"
);
assert_eq!(bundle.formula_table.as_ref().unwrap().rows.len(), 26);
assert_eq!(
bundle
.formula_table
.as_ref()
.unwrap()
.evaluate_numeric_literal(0),
Ok(0.0)
);
assert_eq!(
bundle.init_execution,
MissionScriptInitExecution::Executed { event_index: 0 }
);
let clan_index = unsupported.clan_index;
let states = loaded_mission_script_init_states(&engine).expect("Init writes");
assert!(states.iter().all(|state| state.clan_index != clan_index));
let init = states
.iter()
.find(|state| state.clan_index == 0)
.expect("first clan Init writes");
assert_eq!(
init.writes
.iter()
.map(|write| write.index)
.collect::<Vec<_>>(),
[224, 225, 226, 188, 189, 190, 191]
);
assert!(init.writes[3..]
.iter()
.all(|write| write.value == VarSetDefault::Dword(0)));
let varset = loaded_mission_script_varset_states(&engine)
.expect("per-clan varsets")
.iter()
.find(|state| state.clan_index == clan_index)
.expect("unsupported clan varset");
.find(|state| state.clan_index == 0)
.expect("first clan varset");
assert_eq!(
&varset.values[188..192],
&[
VarSetDefault::Dword(0),
VarSetDefault::Dword(0),
VarSetDefault::Dword(0),
VarSetDefault::Dword(0),
]
);
assert_eq!(
&varset.values[224..227],
&[
VarSetDefault::Dword(950),
VarSetDefault::Dword(1000),
VarSetDefault::Dword(549),
VarSetDefault::Dword(630),
VarSetDefault::Dword(0),
]
);
assert!(loaded.script_bundle_count >= 1);
let full = load_mission(&mut engine, campaign_request(), 100_000)
.expect("full load licensed campaign mission");
assert_eq!(full.script_bundle_count, 4);
assert_eq!(full.script_init_state_count, 4);
assert_eq!(full.script_varset_state_count, 4);
let full_bundles = loaded_mission_script_bundles(&engine).expect("full script bundles");
assert_eq!(full_bundles.len(), 4);
assert!(full_bundles.iter().all(|bundle| matches!(
&bundle.init_execution,
MissionScriptInitExecution::Executed { event_index: 0 }
)));
assert!(full_bundles
.iter()
.all(|bundle| bundle.behavior_warm_start.is_some()));
let full_states = loaded_mission_script_init_states(&engine).expect("full Init writes");
let first_full_init = full_states
.iter()
.find(|state| state.clan_index == 0)
.expect("campaign clan 0 Init");
assert_eq!(
first_full_init
.writes
.iter()
.map(|write| write.index)
.collect::<Vec<_>>(),
[224, 225, 226, 188, 189, 190, 191]
);
assert!(first_full_init.writes[3..]
.iter()
.all(|write| write.value == VarSetDefault::Dword(0)));
let campaign_summary = loaded_mission(&engine).expect("full campaign").clone();
let campaign_bundles = full_bundles.to_vec();
let campaign_init_states = full_states.to_vec();
let campaign_varsets = loaded_mission_script_varset_states(&engine)
.expect("full per-clan varsets")
.to_vec();
let campaign_rng = engine.behavior_rng_state;
let before = step_headless(&mut engine, InputSnapshot, 0)
.expect("step loaded campaign")
.snapshot
.objects;
let single_error = load_mission(
&mut engine,
MissionRequest {
key: "MISSIONS/Single.01/data.tma".to_string(),
mode: MissionMode::SingleMission,
},
100_500,
)
.expect_err("Single.01 reaches the later Handler(43) boundary");
assert!(matches!(
single_error,
EngineError::ScriptInitUnsupported {
clan_index: 1,
path,
event_index: 0,
instruction_index: 10,
source: ScriptInitResolveError::UnsupportedSelector {
found: fparkan_script::ScriptDispatchSelector::Handler(43),
},
} if path.ends_with("scream.scr")
));
assert_eq!(loaded_mission(&engine), Some(&campaign_summary));
assert_eq!(
loaded_mission_script_bundles(&engine),
Some(campaign_bundles.as_slice())
);
assert_eq!(
loaded_mission_script_init_states(&engine),
Some(campaign_init_states.as_slice())
);
assert_eq!(
loaded_mission_script_varset_states(&engine),
Some(campaign_varsets.as_slice())
);
assert_eq!(engine.behavior_rng_state, campaign_rng);
let after = step_headless(&mut engine, InputSnapshot, 0)
.expect("step retained campaign")
.snapshot
.objects;
assert_eq!(before, after);
}
#[test]
#[ignore = "requires licensed corpus"]
fn licensed_single_preview_loads_each_formula_table_and_stops_at_handler43() {
let root = licensed_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
let mut engine = create(EngineServices::new(vfs)).expect("engine");
load_mission_static_preview_roots(
&mut engine,
MissionRequest {
key: "MISSIONS/Single.01/data.tma".to_string(),
mode: MissionMode::SingleMission,
},
NonZeroUsize::new(1).expect("non-zero root count"),
)
.expect("load licensed Single.01 preview");
let bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
assert_eq!(bundles.len(), 2);
let first = bundles
.iter()
.find(|bundle| bundle.clan_index == 0)
.expect("scr_pl_1 bundle");
assert_eq!(
first.base_path.replace('\\', "/"),
"MISSIONS/SCRIPTS/scr_pl_1"
);
assert_eq!(first.formula_table.as_ref().unwrap().rows.len(), 5);
assert_eq!(
first.init_execution,
MissionScriptInitExecution::Executed { event_index: 0 }
);
let second = bundles
.iter()
.find(|bundle| bundle.clan_index == 1)
.expect("scream bundle");
assert_eq!(
second.base_path.replace('\\', "/"),
"MISSIONS/SCRIPTS/scream"
);
let formulas = second.formula_table.as_ref().expect("scream FML");
assert_eq!(formulas.rows.len(), 149);
for (index, expected_bits) in [
0x3ecc_cccd,
0x3f4c_cccd,
0x3f00_0000,
0x3f80_0000,
0x4120_0000,
0x3e4c_cccd,
0x42c8_0000,
0x43c8_0000,
0x4000_0000,
]
.into_iter()
.enumerate()
{
assert_eq!(
formulas
.evaluate_numeric_literal(index as u32)
.expect("referenced native literal")
.to_bits(),
expected_bits
);
}
assert_eq!(
second.init_execution,
MissionScriptInitExecution::Unsupported {
event_index: 0,
instruction_index: 10,
error: ScriptInitResolveError::UnsupportedSelector {
found: fparkan_script::ScriptDispatchSelector::Handler(43),
},
}
);
let states = loaded_mission_script_init_states(&engine).expect("preview Init writes");
assert!(states.iter().any(|state| state.clan_index == 0));
assert!(states.iter().all(|state| state.clan_index != 1));
let first_varset = loaded_mission_script_varset_states(&engine)
.expect("per-clan varsets")
.iter()
.find(|state| state.clan_index == 0)
.expect("scr_pl_1 varset");
assert_eq!(
&first_varset.values[186..188],
&[VarSetDefault::Dword(0), VarSetDefault::Dword(0)]
);
let declarations = loaded_mission_script_varset(&engine)
.expect("shared script varset")
.declarations
.declarations
.iter()
.map(|declaration| declaration.default_value)
.collect::<Vec<_>>();
let second_varset = loaded_mission_script_varset_states(&engine)
.expect("per-clan varsets")
.iter()
.find(|state| state.clan_index == 1)
.expect("scream varset");
assert_eq!(second_varset.values, declarations);
assert!(bundles
.iter()
.all(|bundle| bundle.behavior_warm_start.is_none()));
}
#[test]
@@ -14131,7 +14601,7 @@ mod tests {
assert!(matches!(
error,
EngineError::ScriptInitUnsupported {
source: Handler19ResolveError::UnsupportedSelector {
source: ScriptInitResolveError::UnsupportedSelector {
found: ScriptDispatchSelector::Handler(18),
},
..
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, но не измеряют
control word процесса.
Отдельно проверены загрузка лицензированного AutoDemo в Rust и preview
лицензированной кампании. AutoDemo даёт raw TMA anchor-ы
`[(500.2857, 751.8217), (728.0, 449.0)]` и записи `[(500, 752, 0),
(728, 449, 1)]`; preview кампании сохраняет package и статус `Unsupported`,
не применяя частичные записи. Это подтверждает загрузчик и узкий Init resolver
Rust.
Лицензированный 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`.
Отсутствующий `.fml` не мешает пакету с одними `Handler(19)`; другие ошибки
чтения распространяются как VFS errors.
Нативный конструктор показывает предупреждение при координате выше `10000`, но
продолжает работу. Rust моделирует числовую запись, а не это UI-предупреждение;
на первом `FISTP qword` он явно завершает загрузку ошибкой для NaN, infinity и
значений вне signed-qword диапазона. В первом событии `Init` Rust поддерживает
только action-форму `Handler(19)`: в семи disk header words control находится в
`header_words[3]`, а result-save reference — в `header_words[1]`; для поддержанной
формы оба равны `0xffffffff`. Это подтверждается перестановкой записи в
`ai.dll!FUN_10011b20` (RVA `0x11b20`) и чтениями в исполнителе
`FUN_10012020` (RVA `0x12020`) той же GOG-сборки. `Handler(19)` (`FUN_1000aa38`,
RVA `0xaa38`) использует references `0..2`; `header_words[2]` его result-save
guard-ом не проверяется. Неизвестная инструкция даёт `Unsupported` в preview,
не применяя накопленные частичные записи; при полной загрузке это ошибка. Это
ограничение Rust исполнителя, не правило оригинального dispatcher-а. Ошибочные
ссылки и типы остаются ошибками в обоих режимах.
два доказанных действия: `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]`. Маппинг подтверждён
`ai.dll!FUN_10011b20` (RVA `0x11b20`) и исполнителем `FUN_10012020` (RVA
`0x12020`), SHA-256 `c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`.
`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`. В
`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
`VAR(float|DWORD, name, default)` declarations byte-safe (comments остаются
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:
31 `float` и 200 `DWORD` (от `f0` до `fY`);
loader `ai.dll!0x10001000` сначала открывает `<bundle-base>.var` и только при
`not found` откатывается к этому shared file. Runtime повторяет данный порядок
транзакционно и публикует selected `MissionScriptVarSet` с путём/provenance, но
ещё не исполняет declarations как VM state;
транзакционно, создаёт typed varset cells для кланов и применяет поддержанные
записи первого `Init`. Это начальное состояние сценария, а не исполнение общего
VM state;
`.trf` — NRes tables, чей framing подтверждён, а field semantics местами лишь
consumer-inferred.
@@ -285,9 +287,10 @@ TMA properties остаются four raw `u32` words плюс имя, пока c
Research/economy работают в simulation: `LoadResearch` и
`CalcFullResearchCost` доказывают данные и вычислимую стоимость, но не полный
layout prerequisites/modifiers/unlocks. Formula evaluator требует strict
grammar/version, typed operands, deterministic numeric policy, bounded stack и
явных errors; x87-compatible rounding нужен там, где оно выбирает ветку.
layout prerequisites/modifiers/unlocks. General formula evaluator требует
versioned grammar, typed operands, deterministic numeric policy, bounded stack
и явных errors; x87-compatible rounding нужен там, где оно выбирает ветку.
Описанный ниже literal-путь первого `Init` не закрывает общий evaluator.
### Первый фрагмент dispatcher-а: `Init` и Handler(19)
@@ -362,10 +365,12 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D
показывает исходное предупреждение `MessageBoxA`; оно находится за пределами
этого числового resolver-а.
Ограниченный Rust-исполнитель `VarSet::resolve_handler19_init` ищет только
первое событие `Init`, проходит его инструкции по порядку и возвращает
упорядоченные записи. Runtime применяет их к отдельной копии varset выбранного
клана лишь после успешного прохода всего события. Отсутствующий `Init` даёт
Ограниченный Rust-исполнитель `VarSet::resolve_init` ищет первое событие
`Init` и проходит его инструкции по порядку. Он возвращает записи для
поддержанного `Handler(19)` и числовых literal assignments; прочая форма
останавливает preview на `Unsupported`. Runtime применяет упорядоченные записи
к отдельной копии varset выбранного клана лишь после успешного прохода всего
события. Отсутствующий `Init` даёт
статус `Missing` и не создаёт записей инициализации; пустой первый `Init` даёт
`Executed` без записей. Если форма инструкции пока не поддержана, package
остаётся доступен в preview режиме со статусом `Unsupported`, а default-значения
@@ -375,6 +380,77 @@ nearest-even `FISTP qword`, младший DWORD, преобразование D
Это правила ограниченного Rust-исполнителя, а не обнаруженное поведение
оригинального 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`
В той же GOG-сборке `Behavior.dll` (SHA-256
@@ -622,10 +698,10 @@ tie-векторов подтверждает реакцию `FISTP`, но не
Нижнеуровневый `VarSet::resolve_handler19` принимает уже преобразованные первые
два слова и третье исходное слово, выдаёт три типизированные записи и отклоняет
отсутствующие, выходящие за диапазон или не-`DWORD` цели. Метод
`resolve_handler19_init` добавляет выбор события и остановку на неподдержанной
форме инструкции. Всё остальное содержимое `.scr` сохраняется reader-ом, но
остаётся вне этого исполнения.
отсутствующие, выходящие за диапазон или не-`DWORD` цели. Общий для первого
события метод `VarSet::resolve_init` добавляет этот `Handler(19)` и формулу из
одного numeric literal; остальные формы инструкции и содержимое `.scr`
сохраняются reader-ом, но остаются вне этого исполнения.
### Изменение provider-состояния Control
+22 -5
View File
@@ -168,8 +168,9 @@ handler лишь инициирует execution context (`+0x50 = 1`); его ga
младшие 32 бита, что даёт `0`. Значение `10001` также проходит дальше: оригинал
показывает предупреждение для чисел выше `10000`, но не отменяет создание AI.
Rust исполняет только подтверждённую форму этого действия. Поддержанные записи
применяются к состоянию лишь после успешного прохода всего первого `Init`.
Rust исполняет подтверждённый поднабор первого `Init`: действие `Handler(19)`
и числовые literal assignments, описанные ниже. Поддержанные записи применяются
к состоянию лишь после успешного прохода всего первого `Init`.
Если встретилась неподдержанная форма, preview сохраняет пакет, сообщает
`Unsupported` и оставляет значения по умолчанию; при полной загрузке та же форма
становится ошибкой. Это политика Rust для ограниченного исполнителя, а не правило
@@ -177,6 +178,21 @@ Rust исполняет только подтверждённую форму э
событие считается исполненным без записей. Остальные события пока не образуют
исполняемую 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 настраивает расписание своего первого шага. Он
@@ -196,9 +212,10 @@ SuperAI для записей кланов, затем обновляет Behavi
от повторного входа; при занятой защите вызов пропускается, но одноразовая метка
всё равно устанавливается. В полной загрузке Rust вызывающая сторона передаёт
значение времени очереди; после регистрации объектов загрузчик последовательно
подготавливает каждый сценарный пакет и выполняет только подтверждённые действия
`Handler(19)` из первого `Init`. Статический просмотр не запускает Behavior, но
может сохранить подтверждённые записи `Init` в сценарном varset.
подготавливает каждый сценарный пакет и выполняет восстановленные действия
`Handler(19)` и числовые literal assignments из первого `Init`. Статический
просмотр не запускает Behavior, но может сохранить эти записи `Init` в сценарном
varset.
Это полезное начало сценария, но ещё не целая игровая жизнь AI. Текущий runtime
не воспроизводит аргумент обратного вызова в `AI+0x3fc`, защиту от повторного