aboutsummaryrefslogtreecommitdiff
path: root/docs/tomes/06-behavior.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/tomes/06-behavior.md')
-rw-r--r--docs/tomes/06-behavior.md301
1 files changed, 272 insertions, 29 deletions
diff --git a/docs/tomes/06-behavior.md b/docs/tomes/06-behavior.md
index 48f4405..3a4e51c 100644
--- a/docs/tomes/06-behavior.md
+++ b/docs/tomes/06-behavior.md
@@ -168,8 +168,11 @@ handler лишь инициирует execution context (`+0x50 = 1`); его ga
младшие 32 бита, что даёт `0`. Значение `10001` также проходит дальше: оригинал
показывает предупреждение для чисел выше `10000`, но не отменяет создание AI.
-Rust исполняет только подтверждённую форму этого действия. Поддержанные записи
-применяются к состоянию лишь после успешного прохода всего первого `Init`.
+Rust исполняет подтверждённый поднабор первого `Init`: действие `Handler(19)`
+и формулы из одного атома либо двух атомов с бинарным `+` или `-`. Здесь атом —
+число или имя переменной. Поддержанные шаги сразу обновляют временную копию
+varset, поэтому следующая формула читает уже записанные значения. К состоянию
+клана записи попадают лишь после успешного прохода всего первого `Init`.
Если встретилась неподдержанная форма, preview сохраняет пакет, сообщает
`Unsupported` и оставляет значения по умолчанию; при полной загрузке та же форма
становится ошибкой. Это политика Rust для ограниченного исполнителя, а не правило
@@ -177,8 +180,62 @@ 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`. Например, если `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 мс. Эта
@@ -196,9 +253,10 @@ SuperAI для записей кланов, затем обновляет Behavi
от повторного входа; при занятой защите вызов пропускается, но одноразовая метка
всё равно устанавливается. В полной загрузке Rust вызывающая сторона передаёт
значение времени очереди; после регистрации объектов загрузчик последовательно
-подготавливает каждый сценарный пакет и выполняет только подтверждённые действия
-`Handler(19)` из первого `Init`. Статический просмотр не запускает Behavior, но
-может сохранить подтверждённые записи `Init` в сценарном varset.
+подготавливает каждый сценарный пакет и выполняет восстановленные действия
+`Handler(19)` и поддержанные формульные присваивания из первого `Init`. Статический
+просмотр не запускает Behavior, но может сохранить эти записи `Init` в сценарном
+varset.
Это полезное начало сценария, но ещё не целая игровая жизнь AI. Текущий runtime
не воспроизводит аргумент обратного вызова в `AI+0x3fc`, защиту от повторного
@@ -243,6 +301,25 @@ Rust-тест повторно читает лицензированную миÑ
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, как он затем
+обновляется и как работает полный игровой цикл.
+
Список распределителя хранит не только участников, но и долю каждого. При
добавлении двух объектов оба получают по половине общего веса. Если добавить
третий, движок оставляет долю новой записи около одной трети, а оставшийся вес
@@ -468,16 +545,117 @@ Tag выбирает конкретное правило. Для tag 1 запиÑ
### TRF и preload-данные
-TRF-файлы проходят структурный разбор. `auto.trf`, `data.trf` и tutorial
-variants имеют сигнатуру [NRes](../reference/nres.md) и содержат большие
-таблицы имён игровых прототипов: оружия, башен, сооружений и других объектов.
-Также найдены preload-записи, ANI и SKE resources.
+TRF-архивы используют несколько потребителей; один подтверждённый путь ведёт
+из `Handler(43)` к вычислению стоимости исследования. В проверенной сборке GOG
+`CalcFullResearchCost` из `ArealMap.dll` получает таблицу, загруженную через
+`MisLoad.dll`. Для этого пути `TRF0` содержит строки по десять сырых `u32`,
+`TRF1` — байты флагов, `TRFB` — пары «смещение имени и номер строки», а
+`TRF6` — пул строк с нулевыми окончаниями. Слово 8 строки связывает её с
+`TRFB` через младшие 16 бит; старшая половина сохраняется как неизвестное
+значение. `TRF2` и `TRF3` образуют упорядоченные списки предварительных
+исследований, которые читает этот путь стоимости. `TRF4`/`TRF5` образуют
+отдельную пару таблиц, но `CalcFullResearchCost` её не использует. Непрозрачные
+`TRF7`–`TRFA` остаются в архиве и требуются загрузчику.
+
+Модуль `fparkan-assets` разбирает этот профиль в `ResearchTrfDocument` и
+вычисляет пару результатов для одного Unit DAT:
+
+```rust
+let table = ResearchTrfDocument::decode(archive_bytes)?;
+let cost = calc_full_research_cost(&candidate, &table)?;
+```
+
+Расчёт проходит компоненты Unit DAT в порядке дерева. Компонент с нулевым
+`kind` завершает весь свой участок вместе с потомками. Для остальных ключ
+ресурса ищется среди имён `TRFB`; повтор строки добавляется лишь один раз.
+После прямых компонентов алгоритм обходит список `TRF2`/`TRF3` в его исходном
+порядке, углубляясь сразу в найденные предварительные исследования. Общая
+таблица посещений не даёт повторно добавлять узел и завершает циклы. Два бита
+флагов `TRF1` управляют включением строки и обходом её зависимостей; остальные
+сырые биты и слова не получают игровых имён без отдельного доказательства.
+
+Результат первого native-выхода подписан в отладочной строке как ore и
+суммирует слово 1 `TRF0`, второй подписан как power и суммирует слово 0. Каждое
+сложение сохраняется как `f32` в порядке найденной последовательности. В
+принятом наборе из 23 синтетических тестов оригинальных DLL проверялись
+`CalcFullResearchCost` и два AI-предиката; именно вызовы `CalcFullResearchCost`
+подтвердили порядок обхода, дедупликацию, циклы, флаги и отсечение дерева.
+Две проверки суммы показали чувствительность округления: одна и та же сумма
+`1, 2^24, -2^24` даёт `0`, а в другом порядке `2^24, -2^24, 1` даёт `1`.
+
+Эти результаты относятся к синтетическому исследовательскому контексту;
+штатный Handler(43) и запуск миссии здесь не проверялись.
+
+Владелец начальной записи исследования — индекс текущего прохода по кланам.
+В 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`, задающего число вызовов
+AI-слота `+0x38`. Передача поля подтверждена по статическому коду; выполнение
+Handler(43) и штатный запуск миссии остаются непроверенными. Технические
+сведения и адреса исходных сборок приведены в [границах знания](../appendices/knowledge-boundaries.md).
+
+Полный Rust-загрузчик принимает явный `MissionStartupInput`: время очереди
+и отдельный `research_creation_gate`. Режим миссии и имя файла этот
+флаг не заменяют. При включённом флаге загрузчик использует уже имеющееся
+`MissionAiStartupState` каждого TMA-клана, даже если у него нет сценарного
+пакета; путь берётся из второго ресурса как C-строка до первого NUL. Пустой
+путь и выключенный флаг пропускают чтение, а не-ASCII байты дают явную ошибку.
+Предпросмотр тоже не читает TRF. Клан индексируется по порядку в TMA, а тег
+`second_resource` задаёт число слотов AI, не владельца. Если несколько кланов
+указывают на один файл, каждый получает отдельно декодированную таблицу со
+своими строками TRF0.
+
+```rust
+MissionStartupInput {
+ queue_time_ms: 100_000,
+ research_creation_gate: true,
+}
+```
-По содержимому, порядку загрузки и consumers TRF с высокой вероятностью
-предоставляет AI/сценарному слою заранее подготовленную таблицу типов и
-связанных данных. Framing и имена подтверждены corpus-ом, но полная семантика
-каждой TRF-записи ещё не закрыта. Имена должны разрешаться через тот же
-resource registry, что и миссионные объекты.
+Это сохраняет декодированные таблицы и связь с владельцами для следующих
+этапов. Оно ещё не создаёт полный исследовательский контекст оригинала, не
+строит AI-каталог и не запускает `Handler(43)`; эти части пока не
+восстановлены.
+
+Отдельная frozen-матрица непосредственно вызвала chooser GOG `ai.dll` SHA-256
+`c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc`: функция
+`FUN_10010f90` (RVA `0x10f90`) прошла три guard-case и все 15 проверок.
+Конструктор находится в RVA `0x10480`, raw DAT parser — `0x102c0`,
+предикаты — `0x115f0` и `0x11530`. Матрица передавала кандидатов в заданном
+порядке. Обёртка `Handler(43)` по RVA `0xd54a` и загрузчик AI-каталога по RVA
+`0x104e0` не вызывались. Cost принадлежит `ArealMap.dll` SHA-256
+`a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea`,
+`CalcFullResearchCost` RVA `0x16280`; для сравнения x87 был явно задан как
+`0x007f`.
+
+`ResearchCandidateSet` держит упорядоченный каталог, выбранный индекс, байт
+scan-status и флаг `loaded` каталога; TRF-контекст передаётся отдельно.
+При наличии каталога и контекста set-status начинает со значения `1`, затем
+перед чтением каждого entry со status `0` становится `0`. Ненулевой entry
+пропускается. Первый предикат требует info words 1 и 2, соответствующие битам
+TRF1 0 и 2; второй требует words 0 и 2, то есть биты 1 и 2. Второй status
+сохраняется в entry, а cost вызывается даже при status `1`. Выбор записывается
+сразу, когда second predicate ложен и ore строго ниже текущего минимума,
+изначально `f32::from_bits(0x4b18967f)`. Равенство сохраняет первого, NaN индекс не меняет.
+
+В Rust ошибка VFS-чтения и невалидный DAT пропускают entry; неизвестный TRF-
+ключ даёт false predicate. Ошибка стоимости `EmptyResearchTable` тоже
+пропускает entry и сохраняет его второй status. Остальные ошибки таблицы и
+`UnsupportedNonAsciiLookup` возвращаются явно, причём уже сделанные изменения
+индекса и status остаются. Общий Rust-предикат может закончить обход раньше
+native callback `0x11530`, который продолжает накапливать неиспользуемые float
+sums. Первый проход уже проверил enabled keys в той же неизменной таблице,
+поэтому подтверждается эквивалентность состояния и выбора для этого входа,
+но не callback-trace parity. Это не подтверждение цены на живой миссии или
+порядка AI-каталога.
### Стабильность AI-слоя
@@ -883,21 +1061,41 @@ descriptor tag `19` и положительным selector-0 ответом; пÑ
его vtable slot `+0x10` статически указывает на общий getter RVA `0x21d00`, но
отдельный aggregate oracle этот класс не запускал. Rust хранит начальный scale
factory `9` и предоставляет `query_loaded_mission_control_resource` для selector
-`1`/`2` и `set_loaded_mission_control_multiplier` для setter-а `1`. Вызовы
-сначала проверяют живой `ObjectHandle`, затем обращаются к таблице этого объекта
-из полной загрузки миссии. Синтетические тесты проверяют границы пустой таблицы,
-нулевого знаменателя, неподдержанных factory tags и чисел за диапазоном
-нормального `f32`; лицензированный тест сверяет реальные descriptor tags и
-получает для `min_l_n1.dat` сумму `16` и отношение `1` до setter-а.
-
-В этом API пока реализованы только getter selectors `1`/`2` и setter `1`.
+`1`, `2` и `15`, а также `set_loaded_mission_control_multiplier` для setter-а
+`1`. Вызовы сначала проверяют живой `ObjectHandle`, затем обращаются к таблице
+этого объекта из полной загрузки миссии. Синтетические тесты проверяют границы
+пустой таблицы, нулевого знаменателя, отсутствующих объектов фабрик и чисел за
+диапазоном нормального `f32`; лицензированный тест сверяет реальные descriptor
+tags и получает для `min_l_n1.dat` сумму `16` и отношение `1` до setter-а.
+
+Сырой селектор `0x0f` соединяет два ранее созданных объекта владельца: у строки
+с `factory_tag = 9` берётся коэффициент и его текущий множитель, а у строки с
+`factory_tag = 21` — шесть текущих `coefficient_bits`. `INTO` может поменять
+`descriptor.tag`, не заменяя объект фабрики, поэтому query выбирает последние
+строки по `factory_tag`, а не по текущему tag. Если любой объект фабрики
+отсутствует, ответ — `None`. Иначе шесть коэффициентов складываются с индекса `5`
+к `0` с округлением до `f32` после каждого сложения; результат умножается на
+selector `0` фабрики `9` (`coefficient_bits[0] * class9_selector0_scale_bits`).
+Общие `multiplier_bits`, `node_provider_status` и `native_word_50_bits` в этой
+формуле не участвуют.
+
+Лицензионная проверка загружает AutoDemo с явно переданными
+`MissionStartupInput { queue_time_ms: 100_000, research_creation_gate: false }`
+и вызывает `query_loaded_mission_control_resource(..., 15)` для исходных
+объектов `0..5`; для `6` и `7` Rust возвращает `None`, потому что не хватает как
+минимум одного объекта фабрики. Native-биты для `0..5` приведены в технической
+главе. Проверка сверяет результат исходного геттера на снимке входных полей,
+подготовленном Rust. Время и флаг заданы самим тестом; они не доказывают
+начальный режим запуска AutoDemo в оригинале и не являются захватом его фабрик
+или состояния объекта Control.
+
Отдельный native setter selector `17`, tag-specific методы прочих factory tags,
их внешние эффекты и вызов из регулярного шага Behavior здесь не моделируются.
Числа должны оставаться конечными и нормальными либо точным нулём; произведения
-и отношения за поддерживаемым диапазоном `f32` возвращают явную ошибку, а не
-подменяются нулём или `None`. При проверке диапазона Rust оценивает операцию в
-`f64`, затем выполняет её в `f32`; это ограниченная проверка диапазона, а не
-эмуляция полного x87 accumulator-а.
+и суммы за поддерживаемым диапазоном `f32` возвращают явную ошибку, а не
+подменяются нулём или `None`. При проверке диапазона Rust сначала оценивает
+границы в `f64`, затем выполняет каждую операцию в `f32`; это сохраняет
+пошаговое округление этого getter-а, но не эмулирует весь x87 execution state.
Отдельная ветка того же GOG `AniMesh.dll` показывает, когда создаётся Behavior
для host object. В `FUN_10002ff0` (RVA `0x2ff0`, SHA-256
@@ -941,8 +1139,31 @@ NDP, а `FUN_1000a100` (RVA `0xa100`) добавляет их в общую та
последовательность provider-строк: её длина берётся из NDP, она не дополняется
до числа узлов MSH и не участвует во второй перенумерации ключа.
-Так этот селектор включён в цепочку добычи руды оригинала. В той же сборке
-`Behavior.dll` (SHA-256
+Индексный selector `0x400` выдаёт значение выбранной строки provider как есть.
+Сначала общий индекс устройств выбирает дескриптор, затем его ключ выбирает
+строку NDP; это разные номера. Результатом становится текущее type-1 значение
+этой строки, а не запас `CurrentOre`, предел или значение после умножения на
+коэффициент и `M`. Например, provider-строка с `0.625` возвращает `0.625`, а
+записанный в ней ноль тоже остаётся успешным результатом. Rust использует уже
+подготовленную таблицу и сообщает отдельную ошибку, если provider-таблица или
+строка по ключу недоступна. Этот запрос читает снимок состояния и сам не меняет
+таблицу и не запускает её обновление.
+
+У правила есть два разных подтверждения. Разбор AutoDemo нашёл восемь таблиц
+Control и 100 устройств; все 100 значений по ключам устройств в этом корпусе
+равны `1.0`, поэтому Rust-проверка подтверждает реальные ключи и доступность
+строк, но сама по себе не различает выбор по ключу и по номеру устройства.
+Отдельная x86-проверка исходного `Control.dll` включала шесть вызовов на
+синтетической таблице, где значения строк были заданы вручную и различались.
+Например, при номере устройства `2`, ключе `1` и нулевом слове
+`device+0x50` оригинал вернул `0.625` из выбранной строки. Точные биты ответов
+приведены в [техническом разборе](../appendices/script-vm.md#индексный-control-getter-сырой-селектор-0x400).
+Этот опыт не создавал оригинальные factory-объекты и не исполнял полную миссию:
+полный интерфейс IID `0x204`, интеграция Behavior и игровое поведение остаются
+непроверенными.
+
+В цепочке добычи руды оригинал использует описанный выше selector `0x300`.
+В той же сборке `Behavior.dll` (SHA-256
`ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99`)
`FUN_100186b0` (RVA `0x186b0`) перечисляет устройства типа `0x1a`, а
`FUN_100198e0` (RVA `0x198e0`) суммирует успешные запросы IID `0x204` с selector
@@ -1354,7 +1575,7 @@ offsets 119–121. В `fr_l_gener.ctl` найдено 35 строк type 4, вк
`f_gener_ball` с offset 85.
Control.dll `+0x8B10` передаёт указатель текущей записи диспетчеру `+0x2D4B0`.
-Для tags `2`, `3`, `4`, `5`, `8`, `10`, `21` и `27` подтверждён общий читатель
+Для tags `2`, `3`, `4`, `5`, `8`, `10`, `17`, `21` и `27` подтверждён общий читатель
`+0x21D50`: таблица виртуальных методов tag `2` (`+0x3CBD8`) вызывает его через
обёртку `+0x29650`, остальные перечисленные vtable указывают прямо на него в
позиции `+0x1C`. Читатель потребляет
@@ -1365,6 +1586,28 @@ Control.dll `+0x8B10` передаёт указатель текущей запÐ
а также отказ при оборванном завершающем NUL. Это подтверждает структурный
формат, но не назначает смысл всем коэффициентам устройств.
+Путь записи с меткой `17` проверен на GOG `Control.dll` с SHA-256
+`bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e`:
+ветвь `0x11` фабрики `+0x2D4B0` вызывает конструктор `+0x24760`, который
+устанавливает указатель на виртуальную таблицу `0x1003C840`. Ячейка `+0x1C`
+этой таблицы ссылается прямо на общий читатель `+0x21D50`. В ресурсах
+`weapon.rlb:bm_m_01.ctl` и `weapon.rlb:bm_l_01.ctl` таблица привязок
+заканчивается на смещении `0x120`, где начинается запись с меткой `17`.
+Общий префикс размером `0xB0` байт включает саму метку; счётчик по `+0xAC` и
+слово длины в этих двух ресурсах равны нулю, поэтому указатель чтения
+переходит на `0x1D4`. Там начинаются `0x54` байта заголовка таблиц эффектов;
+таблицы содержат соответственно `4`, `2`, `2` и `2` строки по 100 байт и
+заканчиваются на `0x620`. Записи из реестра объектов `bm_m_01` и `bm_l_01`
+при обычной подготовке ресурсов сохраняют явно указанные списки точек CPT
+(`bm_m_a.cpt` и `bm_l_a.cpt`) и таблицы узлов NDP (`bm_m_01.ndp` и
+`bm_l_01.ndp`); в первой таблице эффектов находятся три строки type 4.
+Синтетический тест проверяет непустые массивы значений и строки у записи с
+меткой 17 и следующей записи с меткой 4, затем переход к таблице эффектов.
+Если считать 17 неизвестной, чтение остановится перед
+ней; общий разбор позволяет пройти запись и прочитать следующие таблицы,
+оставляя слова tag 17 числами без толкования. Обработчики этой метки и их
+игровое поведение ещё не восстановлены.
+
Resolver прототипов хранит явную registry-ссылку на `.cpt` отдельно от ссылки
на `.ctl`. Если строки type 4 разобраны и явная ссылка на CPT есть, подготовка
читает ровно указанные archive/name; имя CPT не выводится из имени CTL. В CPT