feat(runtime): project initial Behavior control groups
Docs Deploy / Build and Deploy MkDocs (push) Successful in 37s

This commit is contained in:
Valentin Popov committed 2026-10-31 08:24:58 +04:00
1 parent 6412c12c09
commit 8c11e0f470
4 files changed
+328

No files matched your search

+246
View File
@@ -495,6 +495,38 @@ pub struct MissionControlDeviceTableState {
pub current_output_rate_5e0_bits: u32, pub current_output_rate_5e0_bits: u32,
} }
/// One initial outer row prepared from the current Control descriptor table.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionBehaviorControlGroup {
/// Table ordinal of the descriptor whose current tag starts this group.
pub header_device_ordinal: usize,
/// Table ordinals of current-tag-2 descriptors following this header.
pub candidate_device_ordinals: Vec<usize>,
/// Table ordinals of current-tag-30 descriptors following this header.
pub tag30_device_ordinals: Vec<usize>,
}
/// Initial grouping of a prepared Control table, before live Behavior refreshes.
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct MissionBehaviorControlGrouping {
/// Groups in the order their current-tag-1 headers appeared.
pub groups: Vec<MissionBehaviorControlGroup>,
/// Current-tag-2 entries before the first header.
pub ungrouped_candidate_device_ordinals: Vec<usize>,
}
/// Initial Behavior grouping projection for one full-load mission object.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct MissionBehaviorControlGroupingState {
/// Stable identity from the source TMA object order.
pub original_id: Option<OriginalObjectId>,
/// Corresponding live Rust world object.
pub handle: ObjectHandle,
/// Initial grouping, or the reason its prepared Control table is unavailable.
pub grouping:
Result<MissionBehaviorControlGrouping, MissionControlDeviceTableUnavailableReason>,
}
/// Provenance for the class passed to Control's constructor. /// Provenance for the class passed to Control's constructor.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum MissionControlClassSource { pub enum MissionControlClassSource {
@@ -974,6 +1006,7 @@ struct LoadedMissionState {
behavior_ore_states: Vec<BehaviorOreState>, behavior_ore_states: Vec<BehaviorOreState>,
behavior_profiles: Vec<BehaviorProfileState>, behavior_profiles: Vec<BehaviorProfileState>,
control_device_tables: Vec<MissionControlDeviceTableState>, control_device_tables: Vec<MissionControlDeviceTableState>,
behavior_control_groupings: Vec<MissionBehaviorControlGroupingState>,
research_trf_tables: Vec<MissionResearchTrfTable>, research_trf_tables: Vec<MissionResearchTrfTable>,
script_bundles: Vec<MissionScriptBundle>, script_bundles: Vec<MissionScriptBundle>,
script_varset: Option<MissionScriptVarSet>, script_varset: Option<MissionScriptVarSet>,
@@ -1720,6 +1753,14 @@ fn load_mission_with_options(
} else { } else {
Vec::new() Vec::new()
}; };
let behavior_control_groupings = if options.asset_scope == MissionAssetScope::Full {
control_device_tables
.iter()
.map(project_mission_behavior_control_grouping)
.collect()
} else {
Vec::new()
};
let mut next_behavior_rng_state = engine.behavior_rng_state; let mut next_behavior_rng_state = engine.behavior_rng_state;
if options.asset_scope == MissionAssetScope::Full { if options.asset_scope == MissionAssetScope::Full {
@@ -1801,6 +1842,7 @@ fn load_mission_with_options(
behavior_ore_states, behavior_ore_states,
behavior_profiles, behavior_profiles,
control_device_tables, control_device_tables,
behavior_control_groupings,
research_trf_tables, research_trf_tables,
script_bundles: loaded_scripts.bundles, script_bundles: loaded_scripts.bundles,
script_varset: loaded_scripts.varset, script_varset: loaded_scripts.varset,
@@ -2132,6 +2174,61 @@ pub fn loaded_mission_control_device_tables(
.map(|state| state.control_device_tables.as_slice()) .map(|state| state.control_device_tables.as_slice())
} }
/// Returns the initial prepared Control grouping for full-load mission objects.
#[must_use]
pub fn loaded_mission_behavior_control_groupings(
engine: &Engine,
) -> Option<&[MissionBehaviorControlGroupingState]> {
engine
.loaded
.as_ref()
.map(|state| state.behavior_control_groupings.as_slice())
}
fn project_mission_behavior_control_grouping(
table: &MissionControlDeviceTableState,
) -> MissionBehaviorControlGroupingState {
let grouping = match &table.status {
MissionControlDeviceTableStatus::Available(devices) => {
Ok(build_mission_behavior_control_grouping(devices))
}
MissionControlDeviceTableStatus::Unavailable(reason) => Err(reason.clone()),
};
MissionBehaviorControlGroupingState {
original_id: table.original_id,
handle: table.handle,
grouping,
}
}
fn build_mission_behavior_control_grouping(
devices: &[EffectiveControlDevice],
) -> MissionBehaviorControlGrouping {
let mut grouping = MissionBehaviorControlGrouping::default();
for (ordinal, device) in devices.iter().enumerate() {
match device.descriptor.tag {
1 => {
grouping.groups.push(MissionBehaviorControlGroup {
header_device_ordinal: ordinal,
candidate_device_ordinals: Vec::new(),
tag30_device_ordinals: Vec::new(),
});
}
2 => match grouping.groups.last_mut() {
Some(group) => group.candidate_device_ordinals.push(ordinal),
None => grouping.ungrouped_candidate_device_ordinals.push(ordinal),
},
30 => {
if let Some(group) = grouping.groups.last_mut() {
group.tag30_device_ordinals.push(ordinal);
}
}
_ => {}
}
}
grouping
}
/// Queries native Control global-table selector 1 (weighted ratio), 2 (sum), or 15. /// Queries native Control global-table selector 1 (weighted ratio), 2 (sum), or 15.
/// `Ok(None)` preserves the original false/no-result behavior for an empty or /// `Ok(None)` preserves the original false/no-result behavior for an empty or
/// zero-denominator table or selector 15 without both cached factory rows. Other /// zero-denominator table or selector 15 without both cached factory rows. Other
@@ -7276,6 +7373,95 @@ mod tests {
} }
} }
#[test]
fn behavior_control_grouping_uses_current_tags_and_table_ordinals() {
let tags = [2, 30, 1, 30, 2, 99, 1, 2, 30, 2, 1];
let devices = tags
.into_iter()
.enumerate()
.map(|(ordinal, current_tag)| {
let mut device = synthetic_effective_control_device(
5,
current_tag,
1.0_f32.to_bits(),
None,
None,
);
device.key = 100 + ordinal as u32;
device.descriptor.fixed_words[1] = 200 + ordinal as u32;
device
})
.collect();
let table =
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(devices));
let state = project_mission_behavior_control_grouping(&table);
assert_eq!(state.original_id, Some(OriginalObjectId(7)));
assert_eq!(
state.handle,
ObjectHandle {
generation: 5,
slot: 7
}
);
assert_eq!(
state.grouping,
Ok(MissionBehaviorControlGrouping {
ungrouped_candidate_device_ordinals: vec![0],
groups: vec![
MissionBehaviorControlGroup {
header_device_ordinal: 2,
candidate_device_ordinals: vec![4],
tag30_device_ordinals: vec![3],
},
MissionBehaviorControlGroup {
header_device_ordinal: 6,
candidate_device_ordinals: vec![7, 9],
tag30_device_ordinals: vec![8],
},
MissionBehaviorControlGroup {
header_device_ordinal: 10,
candidate_device_ordinals: Vec::new(),
tag30_device_ordinals: Vec::new(),
},
],
}),
"group membership uses current descriptor tags and table ordinals, not factory tags or keys"
);
let empty =
synthetic_control_device_table(MissionControlDeviceTableStatus::Available(Vec::new()));
assert_eq!(
project_mission_behavior_control_grouping(&empty).grouping,
Ok(MissionBehaviorControlGrouping::default())
);
let headerless_devices = [2, 30, 2]
.into_iter()
.map(|current_tag| {
synthetic_effective_control_device(5, current_tag, 1.0_f32.to_bits(), None, None)
})
.collect();
let headerless = synthetic_control_device_table(
MissionControlDeviceTableStatus::Available(headerless_devices),
);
assert_eq!(
project_mission_behavior_control_grouping(&headerless).grouping,
Ok(MissionBehaviorControlGrouping {
groups: Vec::new(),
ungrouped_candidate_device_ordinals: vec![0, 2],
})
);
let reason = MissionControlDeviceTableUnavailableReason::NoControlResource;
let unavailable = synthetic_control_device_table(
MissionControlDeviceTableStatus::Unavailable(reason.clone()),
);
assert_eq!(
project_mission_behavior_control_grouping(&unavailable).grouping,
Err(reason)
);
}
fn control_budget_test_table(generator_coefficient: f32) -> MissionControlDeviceTableState { fn control_budget_test_table(generator_coefficient: f32) -> MissionControlDeviceTableState {
let mut generator = synthetic_effective_control_device( let mut generator = synthetic_effective_control_device(
19, 19,
@@ -13548,6 +13734,62 @@ mod tests {
} }
} }
#[test]
#[ignore = "requires licensed corpus"]
fn full_load_builds_initial_behavior_control_groupings_for_autodemo() {
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,
},
MissionStartupInput {
queue_time_ms: 100_000,
research_creation_gate: false,
},
)
.expect("load licensed AutoDemo with explicit startup inputs");
let groupings =
loaded_mission_behavior_control_groupings(&engine).expect("initial Behavior groupings");
let group = |header_device_ordinal, candidate_device_ordinals: &[usize]| {
MissionBehaviorControlGroup {
header_device_ordinal,
candidate_device_ordinals: candidate_device_ordinals.to_vec(),
tag30_device_ordinals: Vec::new(),
}
};
let expected = [
(0, vec![group(6, &[10, 11, 12, 13])]),
(1, vec![group(6, &[10, 11])]),
(2, vec![group(8, &[12, 13, 14])]),
(3, vec![group(6, &[10, 11, 12])]),
(4, Vec::new()),
(5, Vec::new()),
(6, Vec::new()),
(7, Vec::new()),
];
assert_eq!(groupings.len(), expected.len());
for (original_id, expected_groups) in expected {
let state = groupings
.iter()
.find(|state| state.original_id == Some(OriginalObjectId(original_id)))
.expect("grouping for AutoDemo Control object");
assert_eq!(
state.grouping,
Ok(MissionBehaviorControlGrouping {
groups: expected_groups,
ungrouped_candidate_device_ordinals: Vec::new(),
}),
"original object {original_id}"
);
}
}
#[test] #[test]
#[ignore = "requires licensed corpus"] #[ignore = "requires licensed corpus"]
fn full_load_queries_control_selector_15_against_frozen_native_inputs() { fn full_load_queries_control_selector_15_against_frozen_native_inputs() {
@@ -14654,6 +14896,10 @@ mod tests {
) )
.expect("load licensed Single.01 preview"); .expect("load licensed Single.01 preview");
assert!(loaded_mission_behavior_control_groupings(&engine)
.expect("preview grouping projection")
.is_empty());
let bundles = loaded_mission_script_bundles(&engine).expect("script bundles"); let bundles = loaded_mission_script_bundles(&engine).expect("script bundles");
assert_eq!(bundles.len(), 2); assert_eq!(bundles.len(), 2);
let first = bundles let first = bundles
+20
View File
@@ -262,6 +262,26 @@ Behavior (`Behavior.dll`, RVA `0x3600`) вызывает `World3D!stdGetGameMode
с реальными интерфейсами ArealMap, нужными `FUN_10019e80`; полный Behavior tick с реальными интерфейсами ArealMap, нужными `FUN_10019e80`; полный Behavior tick
flow и общий dispatcher событий также остаются открытыми. flow и общий dispatcher событий также остаются открытыми.
`loaded_mission_behavior_control_groupings` публикует только начальную
группировку подготовленной Control-таблицы. Ветка с меткой `2` исходного
`Behavior.dll!FUN_1001be10` (RVA `0x1be10`) дополнительно вызывает
`FUN_1001b4b0` и обращается к свойствам через QI `0x204`, включая запросы с
номерами `0x700`, `6` и `0x400`. Результат `0x700` попадает в строку кандидата
по смещению `+0x3c`, результат `6` — в `+0x40`, а `0x400` может обнулить второе
поле. Их доменный смысл, множитель `0x700`, агрегаты Behavior QI `0x204` и
прочие числовые поля кандидата не восстановлены. Rust не подставляет множитель
`0x700`, нулевые агрегаты, выбранный индекс или эффект. Список для метки `30` не
встречается в ресурсах AutoDemo и проверяется только на искусственном наборе.
Игнорируемая проверка AutoDemo сравнивает порядковые номера записей начальной
группировки, отдельно выведенные по снимку текущих меток дескрипторов, с
состоянием Rust после полной загрузки. Это подтверждает согласованность разбора
подготовленной таблицы и правила группировки на этих ресурсах, но не является
захватом состояния Behavior оригинала и не устанавливает, когда список
прикрепляется или обновляется в живом объекте. Полный штатный путь загрузки и
запуска, агрегаты Behavior QI `0x204`, обновление после изменений Control,
`Handler(43)` и полный игровой цикл остаются отдельными границами.
Начальные свойства `CurrentOre`/`MaximumOre` восстановлены отдельно от Начальные свойства `CurrentOre`/`MaximumOre` восстановлены отдельно от
регулярного обмена. В проверенных `Behavior.dll` (SHA-256 регулярного обмена. В проверенных `Behavior.dll` (SHA-256
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) RVA `ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`) RVA
+43
View File
@@ -763,6 +763,49 @@ tie-векторов подтверждает реакцию `FISTP`, но не
одного numeric literal; остальные формы инструкции и содержимое `.scr` одного numeric literal; остальные формы инструкции и содержимое `.scr`
сохраняются reader-ом, но остаются вне этого исполнения. сохраняются reader-ом, но остаются вне этого исполнения.
### Начальная группировка подготовленных Control-дескрипторов
В проверенной GOG `Behavior.dll` (SHA-256
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`)
`FUN_1001be10` (RVA `0x1be10`) получает список через QI с кодом `0x202` от
`owner+0x5c`. Слоты `+0x50` и `+0x1c` этого интерфейса возвращают число строк и
код-различитель для строки; цикл обрабатывает порядковые номера строк
`0..count-1` по возрастанию. Метки `1`, `2` и `30` начинают группу, добавляют
кандидата и добавляют отдельный элемент к последней группе соответственно.
Запись с меткой `2` до первого заголовка попадает в отдельный вектор; метка `30`
без группы игнорируется, как и все остальные метки.
Для источника списка проверена пара GOG DLL. В `Control.dll` SHA-256
`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e` запрос с
кодом `0x202` отдаёт интерфейс `owner+8`; его методы-обёртки переходят к методам
чтения канонического QI `0x204`, receiver которого равен `owner+0x0c`. Метод
`Control.dll` RVA `0x8830` читает число из `[receiver+0x5b4]`, то есть
`[owner+0x5c0]`; таблица указателей находится в `[receiver+0x5b0]`, то есть
`[owner+0x5bc]`. Поле `[owner+0x5b4]` — отдельный указатель кэша, а не число
строк. Метод чтения `Control.dll` RVA `0x2c390` выбирает указатель `table[index]`,
разыменовывает указатель на дескриптор из записи `+0x48` и возвращает DWORD по
адресу этого дескриптора. Загрузчик `FUN_10021d50` (RVA `0x21d50`) сохраняет там
указатель текущего дескриптора, поэтому код-различитель — его текущая метка, а
не неизменная метка фабрики.
В оригинальном Behavior внешняя строка имеет шаг `0x34`; её `+4` получает
порядковый номер заголовка. Строка-кандидат имеет шаг `0x58`, а её `+8` хранит
порядковый номер кандидата. Номера строк с меткой `30` добавляются в отдельный
вектор последней внешней строки. Rust публикует только эти начальные номера:
`MissionBehaviorControlGroup` и
`MissionBehaviorControlGrouping` группируют строки из уже подготовленной
Control-таблицы в её исходном порядке. `MissionBehaviorControlGroupingState`
сохраняет `original_id`, `ObjectHandle` и либо результат, либо исходную причину
недоступности Control-таблицы. Полная загрузка создаёт проекцию перед публикацией
`LoadedMissionState`; предварительный просмотр возвращает пустой список. Проверки
`behavior_control_grouping_uses_current_tags_and_table_ordinals` и игнорируемый
`full_load_builds_initial_behavior_control_groupings_for_autodemo` покрывают
искусственные граничные случаи и сохранённые номера строк AutoDemo соответственно.
Это начальное разложение подготовленного списка, а не реконструкция полной
инициализации Behavior: отдельные свойства строки-кандидата, её числовые поля,
эффекты и обновление списка после изменений Control здесь не моделируются.
### Изменение provider-состояния Control ### Изменение provider-состояния Control
Для `Control.dll` (SHA-256 Для `Control.dll` (SHA-256
+19
View File
@@ -260,6 +260,25 @@ Rust-тест повторно читает лицензированную ми
TMA-записями и проверяет реальные `ObjectHandle`, но помечен `#[ignore]`, чтобы TMA-записями и проверяет реальные `ObjectHandle`, но помечен `#[ignore]`, чтобы
не требовать оригинальных ресурсов от остальных разработчиков. не требовать оригинальных ресурсов от остальных разработчиков.
После подготовки Control-таблицы можно разобрать и её начальную группировку для
Behavior. Важны текущая метка записи и её порядковый номер в общей таблице:
метка `1` начинает пустую группу, метка `2` становится кандидатом последней
группы, а метка `30` записывает строку в отдельный список той же группы.
Кандидат, встретившийся до первого заголовка, остаётся в списке без группы;
ранняя метка `30` игнорируется. Например,
для текущих меток `[2, 30, 1, 30, 2]` получаются незагруппированный кандидат
`[0]` и группа с заголовком `2`, кандидатом `[4]` и строкой с меткой `30` под
порядковым номером `[3]`. Все номера начинаются с нуля и отсчитываются по общей
Control-таблице, а не по ключам устройств или записям CTLD.
Полная загрузка Rust сохраняет это начальное состояние рядом с подготовленными
Control-таблицами по исходному TMA ID и `ObjectHandle`. Если таблица недоступна,
проекция сохраняет причину ошибки; доступная пустая таблица означает пустую
группировку. При предварительном просмотре список группировок не создаётся. Здесь
разбирается только подготовленный порядок текущих Control-дескрипторов. Проекция
не устанавливает, когда список присоединяется к живому Behavior, как он затем
обновляется и как работает полный игровой цикл.
Список распределителя хранит не только участников, но и долю каждого. При Список распределителя хранит не только участников, но и долю каждого. При
добавлении двух объектов оба получают по половине общего веса. Если добавить добавлении двух объектов оба получают по половине общего веса. Если добавить
третий, движок оставляет долю новой записи около одной трети, а оставшийся вес третий, движок оставляет долю новой записи около одной трети, а оставшийся вес