feat(runtime): run resource schedules during world steps
Docs Deploy / Build and Deploy MkDocs (push) Successful in 34s

This commit is contained in:
Valentin Popov committed 2026-10-29 15:58:33 +04:00
1 parent 5600ebb388
commit e29e3c6bb9
5 files changed
+245 -49

No files matched your search

+204 -33
View File
@@ -1560,27 +1560,36 @@ fn prepare_first_preview_assets<R: ResourceRepository>(
Ok(MissionAssets::default()) Ok(MissionAssets::default())
} }
/// Steps headless mode. /// Steps the world and due Behavior resource distributors at an explicit queue clock.
///
/// Resource updates are prepared before queued world commands and deferred deletion,
/// and published only after the world step succeeds. This does not yet execute the
/// separate Control clock, movement, or general mission script handlers.
/// ///
/// # Errors /// # Errors
/// ///
/// Returns [`EngineError`] when the world step fails. /// Returns [`EngineError`] when the resource or world step fails; neither stage
/// publishes partial state on failure.
pub fn step_headless( pub fn step_headless(
engine: &mut Engine, engine: &mut Engine,
input: InputSnapshot, input: InputSnapshot,
queue_time_ms: u32,
) -> Result<FrameResult, EngineError> { ) -> Result<FrameResult, EngineError> {
Ok(FrameResult { let resources = prepare_loaded_mission_resources(engine, queue_time_ms)?;
snapshot: step(&mut engine.world, &input)?, let snapshot = step(&mut engine.world, &input)?;
}) if let Some(resources) = resources {
resources.apply(engine);
}
Ok(FrameResult { snapshot })
} }
/// Steps rendered mode. /// Steps rendered mode with the same queue-clock semantics as [`step_headless`].
/// ///
/// # Errors /// # Errors
/// ///
/// Returns [`EngineError`] when the world step fails. /// Returns [`EngineError`] when the resource or world step fails.
pub fn frame(engine: &mut Engine) -> Result<FrameResult, EngineError> { pub fn frame(engine: &mut Engine, queue_time_ms: u32) -> Result<FrameResult, EngineError> {
step_headless(engine, InputSnapshot) step_headless(engine, InputSnapshot, queue_time_ms)
} }
/// Returns the loaded mission summary. /// Returns the loaded mission summary.
@@ -1962,9 +1971,65 @@ pub fn advance_loaded_mission_resources(
engine: &mut Engine, engine: &mut Engine,
queue_time_ms: u32, queue_time_ms: u32,
) -> Result<usize, EngineError> { ) -> Result<usize, EngineError> {
let Some(loaded) = engine.loaded.as_ref() else { let Some(resources) = prepare_loaded_mission_resources(engine, queue_time_ms)? else {
return Ok(0); return Ok(0);
}; };
let scheduled_ticks = resources.scheduled_ticks;
resources.apply(engine);
Ok(scheduled_ticks)
}
struct PreparedMissionResources {
distributors: Vec<BehaviorDistributorState>,
ore_states: Vec<BehaviorOreState>,
control_tables: Vec<MissionControlDeviceTableState>,
warm_starts: Vec<Option<BehaviorWarmStartState>>,
rng_state: u32,
scheduled_ticks: usize,
}
impl PreparedMissionResources {
fn apply(self, engine: &mut Engine) {
let loaded = engine
.loaded
.as_mut()
.expect("prepared resources require a loaded mission");
loaded.behavior_distributors = self.distributors;
loaded.behavior_ore_states = self.ore_states;
loaded.control_device_tables = self.control_tables;
for (bundle, warm_start) in loaded.script_bundles.iter_mut().zip(self.warm_starts) {
bundle.behavior_warm_start = warm_start;
}
engine.behavior_rng_state = self.rng_state;
}
}
fn prepare_loaded_mission_resources(
engine: &Engine,
queue_time_ms: u32,
) -> Result<Option<PreparedMissionResources>, EngineError> {
let Some(loaded) = engine.loaded.as_ref() else {
return Ok(None);
};
if loaded
.behavior_distributors
.iter()
.filter(|distributor| distributor.super_ai_visible)
.all(|distributor| {
loaded
.script_bundles
.iter()
.find(|bundle| bundle.clan_index == distributor.clan_index)
.and_then(|bundle| bundle.behavior_warm_start)
.is_some_and(|schedule| {
schedule.first_time_ms != 0
&& schedule.next_deadline_ms != 0
&& queue_time_ms <= schedule.next_deadline_ms
})
})
{
return Ok(None);
}
let mut distributors = loaded.behavior_distributors.clone(); let mut distributors = loaded.behavior_distributors.clone();
let mut ore_states = loaded.behavior_ore_states.clone(); let mut ore_states = loaded.behavior_ore_states.clone();
@@ -2023,18 +2088,14 @@ pub fn advance_loaded_mission_resources(
schedule.cleared_words = totals; schedule.cleared_words = totals;
} }
let loaded = engine Ok(Some(PreparedMissionResources {
.loaded distributors,
.as_mut() ore_states,
.expect("loaded mission was checked above"); control_tables,
loaded.behavior_distributors = distributors; warm_starts,
loaded.behavior_ore_states = ore_states; rng_state,
loaded.control_device_tables = control_tables; scheduled_ticks,
for (bundle, warm_start) in loaded.script_bundles.iter_mut().zip(warm_starts) { }))
bundle.behavior_warm_start = warm_start;
}
engine.behavior_rng_state = rng_state;
Ok(scheduled_ticks)
} }
/// Sets native Control global-table selector 1 on rows whose selector-0 value /// Sets native Control global-table selector 1 on rows whose selector-0 value
@@ -5169,7 +5230,7 @@ mod tests {
#[test] #[test]
fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() { fn load_mission_requires_vfs_and_keeps_world_unchanged_on_error() {
let mut engine = create(EngineServices::default()).expect("engine"); let mut engine = create(EngineServices::default()).expect("engine");
let before = step_headless(&mut engine, InputSnapshot).expect("step"); let before = step_headless(&mut engine, InputSnapshot, 0).expect("step");
let err = load_mission( let err = load_mission(
&mut engine, &mut engine,
MissionRequest { MissionRequest {
@@ -5180,7 +5241,7 @@ mod tests {
) )
.expect_err("missing VFS"); .expect_err("missing VFS");
assert!(matches!(err, EngineError::MissingVfs)); assert!(matches!(err, EngineError::MissingVfs));
let after = step_headless(&mut engine, InputSnapshot).expect("step"); let after = step_headless(&mut engine, InputSnapshot, 0).expect("step");
assert_eq!(before.snapshot.objects, after.snapshot.objects); assert_eq!(before.snapshot.objects, after.snapshot.objects);
} }
@@ -6909,6 +6970,116 @@ mod tests {
); );
} }
#[test]
#[ignore = "requires licensed corpus"]
fn headless_step_runs_resource_schedule_and_rolls_back_both_error_paths() {
let mut engine = create(EngineServices::new(Arc::new(DirectoryVfs::new(
licensed_root("IS"),
))))
.expect("engine");
load_mission(
&mut engine,
MissionRequest {
key: "MISSIONS/Autodemo.00/data.tma".into(),
mode: MissionMode::AutoDemo,
},
100_000,
)
.expect("licensed mission");
let schedules = |engine: &Engine| {
loaded_mission_script_bundles(engine)
.unwrap()
.iter()
.map(|bundle| bundle.behavior_warm_start)
.collect::<Vec<_>>()
};
let initial = schedules(&engine);
frame(&mut engine, 100_200).expect("deadline equality");
assert_eq!(schedules(&engine), initial);
let result = step_headless(&mut engine, InputSnapshot, 100_201).expect("due clan 1");
assert_eq!(result.snapshot.tick.0, 2);
let after = schedules(&engine);
assert_eq!(after[0], initial[0]);
let updated = after[1].expect("clan 1 schedule");
assert_eq!(updated.first_time_ms, 100_201);
assert!(f32::from_bits(updated.cleared_words[0]) > 0.0);
let resources = |engine: &Engine| {
let state = engine.loaded.as_ref().unwrap();
(
state.behavior_distributors.clone(),
state.behavior_ore_states.clone(),
state.control_device_tables.clone(),
schedules(engine),
engine.behavior_rng_state,
)
};
let saved_controls = engine
.loaded
.as_ref()
.unwrap()
.control_device_tables
.clone();
let plant = engine
.loaded
.as_ref()
.unwrap()
.behavior_distributors
.iter()
.find(|distributor| distributor.clan_index == 1)
.unwrap()
.registered_objects
.iter()
.find(|object| object.class_flags == 0x8000_0010)
.unwrap()
.handle;
engine
.loaded
.as_mut()
.unwrap()
.control_device_tables
.retain(|table| table.handle != plant);
let before = resources(&engine);
let world_before = fparkan_world::canonical_state_hash(&engine.world);
assert!(matches!(
step_headless(&mut engine, InputSnapshot, 100_500),
Err(EngineError::BehaviorDistributorTick { clan_index: 1, .. })
));
assert_eq!(resources(&engine), before);
assert_eq!(
fparkan_world::canonical_state_hash(&engine.world),
world_before
);
engine.loaded.as_mut().unwrap().control_device_tables = saved_controls;
let stale = construct_object(&mut engine.world, ObjectDraft { original_id: None }).unwrap();
register_object(&mut engine.world, stale).unwrap();
fparkan_world::enqueue(
&mut engine.world,
fparkan_world::WorldCommand {
sequence: 0,
target: Some(stale),
},
)
.unwrap();
fparkan_world::request_delete(&mut engine.world, stale).unwrap();
let before = resources(&engine);
let world_before = fparkan_world::canonical_state_hash(&engine.world);
assert!(matches!(
step_headless(&mut engine, InputSnapshot, 100_500),
Err(EngineError::World(_))
));
assert_eq!(
resources(&engine),
before,
"a failed world step must not publish staged resource ticks"
);
assert_eq!(
fparkan_world::canonical_state_hash(&engine.world),
world_before
);
}
#[test] #[test]
#[ignore = "requires licensed corpus"] #[ignore = "requires licensed corpus"]
fn licensed_autodemo_resource_ticks_use_queue_time_and_real_generator_profile() { fn licensed_autodemo_resource_ticks_use_queue_time_and_real_generator_profile() {
@@ -7714,7 +7885,7 @@ mod tests {
) )
.expect("static preview does not require Behavior profile parsing"); .expect("static preview does not require Behavior profile parsing");
let previous_mission = loaded_mission(&engine).expect("loaded preview").clone(); let previous_mission = loaded_mission(&engine).expect("loaded preview").clone();
let before = step_headless(&mut engine, InputSnapshot) let before = step_headless(&mut engine, InputSnapshot, 0)
.expect("step preview world") .expect("step preview world")
.snapshot .snapshot
.objects; .objects;
@@ -7726,7 +7897,7 @@ mod tests {
EngineError::Nres { ref path, .. } if path == "behpsp.res" EngineError::Nres { ref path, .. } if path == "behpsp.res"
)); ));
assert_eq!(loaded_mission(&engine), Some(&previous_mission)); assert_eq!(loaded_mission(&engine), Some(&previous_mission));
let after = step_headless(&mut engine, InputSnapshot) let after = step_headless(&mut engine, InputSnapshot, 0)
.expect("step retained preview world") .expect("step retained preview world")
.snapshot .snapshot
.objects; .objects;
@@ -7921,7 +8092,7 @@ mod tests {
denied, denied,
}); });
let mut engine = create(EngineServices::new(vfs)).expect("engine"); let mut engine = create(EngineServices::new(vfs)).expect("engine");
let before = step_headless(&mut engine, InputSnapshot).expect("before"); let before = step_headless(&mut engine, InputSnapshot, 0).expect("before");
let err = load_mission(&mut engine, mission, 0).expect_err("load error"); let err = load_mission(&mut engine, mission, 0).expect_err("load error");
match denied { match denied {
DenyRule::Suffix("Land.map") => assert!(matches!(err, EngineError::Vfs { .. })), DenyRule::Suffix("Land.map") => assert!(matches!(err, EngineError::Vfs { .. })),
@@ -7931,7 +8102,7 @@ mod tests {
DenyRule::Suffix(unexpected) => panic!("unexpected deny rule {unexpected}"), DenyRule::Suffix(unexpected) => panic!("unexpected deny rule {unexpected}"),
} }
assert!(loaded_mission(&engine).is_none()); assert!(loaded_mission(&engine).is_none());
let after = step_headless(&mut engine, InputSnapshot).expect("after"); let after = step_headless(&mut engine, InputSnapshot, 0).expect("after");
assert_eq!(before.snapshot.objects, after.snapshot.objects); assert_eq!(before.snapshot.objects, after.snapshot.objects);
} }
} }
@@ -7942,7 +8113,7 @@ mod tests {
let root = licensed_root("IS"); let root = licensed_root("IS");
let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root)); let vfs: Arc<dyn Vfs> = Arc::new(DirectoryVfs::new(root));
let mut engine = create(EngineServices::new(vfs)).expect("engine"); let mut engine = create(EngineServices::new(vfs)).expect("engine");
let before = step_headless(&mut engine, InputSnapshot).expect("before"); let before = step_headless(&mut engine, InputSnapshot, 0).expect("before");
let err = load_mission_with_options( let err = load_mission_with_options(
&mut engine, &mut engine,
@@ -7967,7 +8138,7 @@ mod tests {
} }
)); ));
assert!(loaded_mission(&engine).is_none()); assert!(loaded_mission(&engine).is_none());
let after = step_headless(&mut engine, InputSnapshot).expect("after"); let after = step_headless(&mut engine, InputSnapshot, 0).expect("after");
assert_eq!(before.snapshot.objects, after.snapshot.objects); assert_eq!(before.snapshot.objects, after.snapshot.objects);
} }
@@ -8011,7 +8182,7 @@ mod tests {
.expect("previous Behavior distributors") .expect("previous Behavior distributors")
.to_vec(); .to_vec();
let previous_rng = engine.behavior_rng_state; let previous_rng = engine.behavior_rng_state;
let before = step_headless(&mut engine, InputSnapshot) let before = step_headless(&mut engine, InputSnapshot, 0)
.expect("step previous world") .expect("step previous world")
.snapshot .snapshot
.objects; .objects;
@@ -8038,7 +8209,7 @@ mod tests {
Some(previous_distributors.as_slice()) Some(previous_distributors.as_slice())
); );
assert_eq!(engine.behavior_rng_state, previous_rng); assert_eq!(engine.behavior_rng_state, previous_rng);
let after = step_headless(&mut engine, InputSnapshot) let after = step_headless(&mut engine, InputSnapshot, 0)
.expect("step retained world") .expect("step retained world")
.snapshot .snapshot
.objects; .objects;
@@ -8217,7 +8388,7 @@ mod tests {
let mut snapshot = None; let mut snapshot = None;
for _ in 0..10_000 { for _ in 0..10_000 {
snapshot = Some( snapshot = Some(
step_headless(&mut engine, InputSnapshot) step_headless(&mut engine, InputSnapshot, 0)
.expect("selected mission deterministic tick") .expect("selected mission deterministic tick")
.snapshot, .snapshot,
); );
+6 -3
View File
@@ -380,11 +380,14 @@ RVA `0xbcf0` для остальных. Это подтверждает стат
Отдельный API `advance_loaded_mission_resources` принимает явное время очереди Отдельный API `advance_loaded_mission_resources` принимает явное время очереди
и обновляет представленные поля `CurrentOre`, строк распределителя и Control и обновляет представленные поля `CurrentOre`, строк распределителя и Control
selector `1`. API не подключён к игровому циклу и не обновляет Control amount, selector `1`. Тот же расчёт вызывают `step_headless` и `frame` с явным
профили, special object-state gate или жизненный цикл объектов. Если обязательный clock очереди; подготовка ресурсов и мировой шаг завершаются атомарно.
Оконное приложение пока использует static preview. Этот путь не обновляет
Control amount, профили или special object-state gate. Если обязательный
запрос позднего объекта не поддержан, транзакция возвращает ошибку без применения запрос позднего объекта не поддержан, транзакция возвращает ошибку без применения
подготовленных изменений. AutoDemo-тест подтверждает связь настоящих профилей с подготовленных изменений. AutoDemo-тест подтверждает связь настоящих профилей с
этим шагом, но не полный паритет миссии; отдельный aggregate getter Behavior RVA этим шагом и проверяет откат при ошибке ресурсов или мировой очереди,
но не полный паритет миссии; отдельный aggregate getter Behavior RVA
`0xa490` в oracle не исполнен. `0xa490` в oracle не исполнен.
Behavior selectors `0x1002` и `0x1005` используют значения выбранного Behavior selectors `0x1002` и `0x1005` используют значения выбранного
+13 -5
View File
@@ -408,9 +408,11 @@ bundle, но остальные потребители исходного гло
В Rust также нет значения аргумента в `AI+0x3fc`, recursion guard `AI+0x78` В Rust также нет значения аргумента в `AI+0x3fc`, recursion guard `AI+0x78`
или одноразового latch `AI+0x8a8`. Поэтому это связанный начальный поднабор, а или одноразового latch `AI+0x8a8`. Поэтому это связанный начальный поднабор, а
не полная активация SuperAI. После полной загрузки узкая Rust-реализация не полная активация SuperAI. После полной загрузки узкая Rust-реализация
вызывается явно через `advance_loaded_mission_resources(engine, queue_time_ms)`; вызывается из `step_headless(engine, input, queue_time_ms)` и
runtime не читает системную очередь, а приложение пока не вызывает API из `frame(engine, queue_time_ms)`. Отдельный
игрового цикла. Шаг использует разрешённые handles и представленные snapshots `advance_loaded_mission_resources(engine, queue_time_ms)` остаётся доступен
для прямого ресурсного шага. Runtime не читает системную очередь, а оконное
приложение пока использует static preview. Шаг использует разрешённые handles и представленные snapshots
профилей и Control, поэтому не воспроизводит отсутствующие в них callbacks. При профилей и Control, поэтому не воспроизводит отсутствующие в них callbacks. При
ошибке ограниченного `Init` Rust отклоняет полную загрузку и сохраняет прежний ошибке ограниченного `Init` Rust отклоняет полную загрузку и сохраняет прежний
мир и состояние генератора; такая политика отката принадлежит Rust loader-у. мир и состояние генератора; такая политика отката принадлежит Rust loader-у.
@@ -479,8 +481,14 @@ selector `1` на интерфейсе получателя становится
только для полного режима; preview остаётся статическим. Явный API только для полного режима; preview остаётся статическим. Явный API
`advance_loaded_mission_resources` использует очередь времени, расписание, `advance_loaded_mission_resources` использует очередь времени, расписание,
профили и представленные Control-таблицы, чтобы исполнить этот ограниченный профили и представленные Control-таблицы, чтобы исполнить этот ограниченный
шаг над загруженным состоянием. `step_headless` по-прежнему шагает только шаг над загруженным состоянием. `step_headless` и `frame` требуют явный
`World`. `u32` clock и используют ту же подготовку: ресурсные изменения, расписания и
RNG остаются временными до успешного `World::step`. Ошибка ресурсов не
продвигает мир; ошибка мировой очереди не публикует ресурсные изменения.
Игнорируемый тест `headless_step_runs_resource_schedule_and_rolls_back_both_error_paths`
на AutoDemo проверяет равенство срока, исполнение следующего шага и обе ветви
отказа, включая отсутствие Control-таблицы у позднего клана и устаревший handle
в очереди. Это не запускает отдельный clock Control или полную миссионную VM.
### Добавление и нормализация долей ### Добавление и нормализация долей
+7
View File
@@ -363,6 +363,13 @@ CollObject, Control и PhysicalModel, пропуская отсутствующ
Статическая цепочка ещё не доказывает исполнение активного Control update на Статическая цепочка ещё не доказывает исполнение активного Control update на
миссии. Rust пока сохраняет начальные provider-снимки и не изменяет их этим путём. миссии. Rust пока сохраняет начальные provider-снимки и не изменяет их этим путём.
В текущем runtime `step_headless` и `frame` получают время очереди явным
аргументом `u32`. По нему запускается представленный ресурсный планировщик
Behavior, после чего обрабатываются очередь мира и отложенные удаления.
Ресурсные изменения публикуются после успешного мирового шага, поэтому ошибка
не оставляет мир и ресурсы на разных шагах. Это начало расчётного цикла;
отдельное обновление Control, общая VM и движение ещё требуют подключения.
## World3D ## World3D
World3D связывает игровые объекты, события, время, ввод, камеру, сетевые World3D связывает игровые объекты, события, время, ввод, камеру, сетевые
+15 -8
View File
@@ -202,17 +202,17 @@ SuperAI для записей кланов, затем обновляет Behavi
Это полезное начало сценария, но ещё не целая игровая жизнь AI. Текущий runtime Это полезное начало сценария, но ещё не целая игровая жизнь AI. Текущий runtime
не воспроизводит аргумент обратного вызова в `AI+0x3fc`, защиту от повторного не воспроизводит аргумент обратного вызова в `AI+0x3fc`, защиту от повторного
входа `AI+0x78`, одноразовую метку `AI+0x8a8` или обновления Behavior на игровых входа `AI+0x78`, одноразовую метку `AI+0x8a8` или полное обновление Behavior на игровых
тиках. При этом один конкретный шаг Behavior — распределение ресурсов — уже тиках. Распределение ресурсов исполнено на исходной DLL с искусственными
исполнен на исходной DLL с искусственными объектами. Rust пока не вызывает объектами и подключено к Rust `step_headless`: явное время очереди определяет,
регулярный шаг: игровой мир ещё не предоставляет нужные ресурсные интерфейсы. какой клан должен выполнить очередной ресурсный шаг.
При полной загрузке Rust уже восстанавливает членство объектов в клановых При полной загрузке Rust уже восстанавливает членство объектов в клановых
списках Behavior и задаёт их начальные доли. Остальные слова записи хранятся списках Behavior и задаёт их начальные доли. Остальные слова записи хранятся
сырыми: семантика только первого из пяти слов хвоста установлена для сырыми: семантика только первого из пяти слов хвоста установлена для
нормализации. нормализации.
Runtime также не подменяет очередь оригинала: вызывающая сторона сама передаёт Runtime также не подменяет очередь оригинала: вызывающая сторона сама передаёт
время, а локальный генератор Rust пока учитывает только случайные числа время, а локальный генератор Rust пока учитывает только случайные числа
восстановленного стартового шага. восстановленного стартового шага и ресурсного планировщика.
### Откуда берутся объекты распределителя ### Откуда берутся объекты распределителя
@@ -355,9 +355,16 @@ Control selector `1`; неподдержанное свойство, запро
арифметическое значение возвращает ошибку. Обновления нескольких кланов, их арифметическое значение возвращает ошибку. Обновления нескольких кланов, их
расписания и RNG фиксируются одной транзакцией: если обязательный запрос расписания и RNG фиксируются одной транзакцией: если обязательный запрос
позднего клана не разрешён, ранее подготовленные изменения остаются позднего клана не разрешён, ранее подготовленные изменения остаются
неприменёнными. Приложение пока не вызывает неприменёнными. Обычные `step_headless(engine, input, queue_time_ms)` и
этот API из игрового цикла, а динамические callbacks, которые переносят `frame(engine, queue_time_ms)` запускают этот расчёт до обработки очереди мира.
количество между Control provider-строками, ещё не моделируются. Поэтому это Они сначала готовят ресурсные изменения, затем выполняют шаг мира и публикуют
ресурсное состояние после его успеха. Ошибка любой стадии сохраняет прежние
ресурсы; ошибка подготовки также не продвигает мировой tick. До срока
планировщика большие таблицы ресурсов не копируются.
Оконное приложение пока запускает static preview и не вызывает этот расчётный
путь. Динамические callbacks, которые переносят количество между Control
provider-строками, ещё не моделируются. Поэтому это
исполняемый загруженный resource step, а не полный симулятор миссии. исполняемый загруженный resource step, а не полный симулятор миссии.
Для настоящих ресурсов AutoDemo тест проходит несколько временных точек: Для настоящих ресурсов AutoDemo тест проходит несколько временных точек: