141 KiB
VI. Поведение, управление, звук и сеть
Шестой том описывает подсистемы, которые превращают загруженный мир в реагирующую игру: AI, Behavior, Wizard, Control, ввод, камеру, звук и сеть. Эти области нельзя восстанавливать только по структуре файлов. Для них важны порядок кадра, ownership объектов, timing событий и доказуемые границы между решением, движением, presentation и транспортом.
Ключевой принцип: reader compatibility не равна gameplay compatibility. Корректно разобранный ресурс ещё не доказывает, что runtime выбирает ту же цель, строит тот же маршрут, применяет ту же collision correction, создаёт тот же sound event или отправляет тот же network payload. Поэтому все утверждения ниже разделяют подтверждённую структуру, восстановленный архитектурный контракт и открытые участки, требующие динамической трассировки.
AI / mission script
-> стратегическая цель, условия, команды миссии
Behavior
-> состояние объекта, target, global/local path
Wizard
-> локальная коррекция траектории
Control
-> physical step, collision proxy, итоговый transform
World3D
-> очередь событий, ownership, deferred deletion
Render / Sound / Net
-> представление, listener, mirrors и сообщения
Связанные главы: мир и миссии, геометрия и рендер и справочный render frame.
AI, Behavior и Wizard
Iron3D разделяет стратегическое принятие решений, поведение конкретного объекта и локальную коррекцию движения. Это разделение должно сохраниться в новой реализации: стратегический AI не меняет transform напрямую, а collision manager не выбирает игровую цель.
ai.dll / SuperAI
-> цель клана, миссии и группы
Behavior.dll
-> состояние юнита, target, global path, local corridor
Wizard.dll
-> ближайшая допустимая траектория
Control.dll
-> физическое движение и столкновения
Behavior
CreateBehaviour создаёт controller для отдельного игрового объекта.
CreateDistributor восстановлен по consumers как посредник распределения
команд или ресурсов; это высокоуверенный архитектурный вывод, а не доказанное
имя внутреннего класса. Behavior получает IArealMap через AI/клановый
контекст, ведёт radar/target state, строит global path, превращает его в local
corridor и передаёт движение Wizard.
Ошибочные состояния проверяются явно:
- отсутствует system map;
- отсутствует terrain interface;
- active behavior не имеет
IArealMap; - объект попал в non-reachable area;
- объект пытается выйти из non-walkable area;
- path generator вошёл в infinite cycle.
Эти случаи являются fatal или diagnostic conditions. Совместимая реализация не должна тихо исправлять их teleport-ом, потому что такое исправление скрывает ошибку areal graph, terrain query или state machine.
Параметры Behavior.ini
Подтверждены настройки:
PathFind_BuildingHitDist
PathFind_BuildingNearestDist
PathFind_NearBuildSpeedPercent
PathFind_CorridorRadius
PathFind_NearDoorCoeff
PathFind_fStepOffBuilding
PathFind_MaxAccel
PathFind_MaxRotation
PathFind_fStepDist
PathFind_MinPointInTrajectory
Network_ResourceTransferMaxDelay
Они задают геометрию corridor, дистанции реакции на здания, снижение скорости
возле препятствий, пределы ускорения и поворота, дискретизацию trajectory и
сетевой timeout передачи ресурсов. Значения читаются как runtime-конфигурация,
а не компилируются в код. Parser должен поддерживать комментарии //, пробелы
вокруг = и CRLF.
Файл также содержит logging/debug switches: Behavior.log, уровни ошибок,
show vectors и z-buffer debug. Эти переключатели полезны не только для
совместимости, но и как модель современных trace flags.
Wizard
Wizard получает желаемое направление и corridor, анализирует ближайшие
ограничения и выдаёт скорректированную локальную траекторию. Behavior может
очищать её через ClearWizardPath при смене цели, повреждении global path или
переходе объекта в неактивное состояние.
Нужно различать четыре уровня движения:
- global path -- последовательность areals;
- local path -- точки или сегменты внутри corridor;
- wizard path -- краткосрочное движение с учётом ближайших препятствий;
- physical step -- фактически разрешённое Control перемещение.
Хранение всего маршрута одним массивом лишает систему возможности локально обойти препятствие без полного повторного поиска. Граница Behavior/Wizard существует именно для того, чтобы краткосрочная геометрическая коррекция не ломала стратегический path state.
SuperAI и миссионные сценарии
CreateSuperAI создаёт центральный controller клана; GetSuperAI возвращает
его. AI загружает файлы из MISSIONS\SCRIPTS\, проверяет версию и пишет ошибки
в ai.log. Несовпадение версии является отдельной ошибкой, а не неизвестной
командой.
Сценарный корпус содержит binary .scr, formula exports .fml, таблицу
переменных varset.var и .trf-данные. .scr хранит именованные секции и
события, например Init, Mission, Problems0, Fort_Task_Complete и
Hero_Teleported, вместе с числовыми ссылками на compiled instructions.
.fml является текстовым экспортом formula set. varset.var декларативно
описывает типы, defaults, ranges и строки через макросоподобные формы
VAR(...) и STRING(...).
Compiled package больше не opaque blob: fparkan-script losslessly читает
проверенный внешний framing. Сначала идут opcode_handler_count и
event_count (u32 LE), затем именованные события с NUL-terminated raw
именем и nested records. Reader сохраняет все seven raw header words и
references каждого record, жёстко ограничивает counts/allocations и отдельно
сохраняет trailing bytes. На c1m2p.scr GOG reader получает 73 handlers,
9 events, 17 records и 20 references без trailing bytes. Это структура
файла, но не таблица семантик: названия opcode/words появятся только после
handler contracts и runtime traces.
Связь первого header word с dispatch доказана статически: ai.dll
создаёт 73 handler pointers в известном порядке и копирует table без
перестановки. По всем 58 GOG packages первый word — индекс 0..72 либо
0xffff_ffff sentinel; fparkan-script отражает это как typed
ScriptDispatchSelector, сохраняя неожиданные значения Unknown. Первый
handler лишь инициирует execution context (+0x50 = 1); его gameplay meaning
пока не назван.
Когда в .scr есть событие Init, сценарная машина ищет его имя как C-строку:
сравнение учитывает регистр, останавливается на первом NUL, а среди повторов
выбирает первое событие. В проверенном AutoDemo это событие содержит один
Handler(19). Он записывает в переменные клана координаты его основания и номер
клана:
первый Init -> действия по порядку -> ClanBaseX, ClanBaseY, ClanID
На пути к этим переменным координаты сначала округляются оригиналом до ближайшего
целого с правилом ties-to-even, затем сохраняется младшее 32-битное слово.
Например, -0.6 даёт 0xffffffff. AI хранит это слово как float, поэтому
0xffffffff становится 2^32; Handler(19) усекает значение и снова берёт
младшие 32 бита, что даёт 0. Значение 10001 также проходит дальше: оригинал
показывает предупреждение для чисел выше 10000, но не отменяет создание AI.
Rust исполняет только подтверждённую форму этого действия. Поддержанные записи
применяются к состоянию лишь после успешного прохода всего первого Init.
Если встретилась неподдержанная форма, preview сохраняет пакет, сообщает
Unsupported и оставляет значения по умолчанию; при полной загрузке та же форма
становится ошибкой. Это политика Rust для ограниченного исполнителя, а не правило
оригинального обработчика. Отсутствующее событие не создаёт записей; пустое
событие считается исполненным без записей. Остальные события пока не образуют
исполняемую VM.
Первый запуск сценария
До регулярной работы Behavior настраивает расписание своего первого шага. Он берёт текущее 32-битное время игровой очереди и назначает проверку через три интервала по 64 мс, добавляя небольшую случайную задержку от 0 до 63 мс. Эта задержка нужна каждому экземпляру, но случайные числа берутся из общего генератора, поэтому порядок запуска кланов влияет на их значения. При первой подготовке controller также очищает несколько внутренних слов. В проверенном повторном вызове до назначенного срока ненулевое сохранённое время оставило срок прежним, не потребовало нового случайного числа и не очистило эти слова; поведение после срока пока неизвестно.
В оригинале вызов идёт через обратный вызов движка: Iron3D сначала создаёт
SuperAI для записей кланов, затем обновляет Behavior и ищет Init для
соответствующего AI. Если Init не найдено, одноразовая метка остаётся
выключенной. Если оно найдено, диспетчер запускается только при свободной защите
от повторного входа; при занятой защите вызов пропускается, но одноразовая метка
всё равно устанавливается. В полной загрузке Rust вызывающая сторона передаёт
значение времени очереди; после регистрации объектов загрузчик последовательно
подготавливает каждый сценарный пакет и выполняет только подтверждённые действия
Handler(19) из первого Init. Статический просмотр не запускает Behavior, но
может сохранить подтверждённые записи Init в сценарном varset.
Это полезное начало сценария, но ещё не целая игровая жизнь AI. Текущий runtime
не воспроизводит аргумент обратного вызова в AI+0x3fc, защиту от повторного
входа AI+0x78, одноразовую метку AI+0x8a8 или полное обновление Behavior на игровых
тиках. Распределение ресурсов исполнено на исходной DLL с искусственными
объектами и подключено к Rust step_headless: явное время очереди определяет,
какой клан должен выполнить очередной ресурсный шаг.
При полной загрузке Rust уже восстанавливает членство объектов в клановых
списках Behavior и задаёт их начальные доли. Остальные слова записи хранятся
сырыми: семантика только первого из пяти слов хвоста установлена для
нормализации.
Runtime также не подменяет очередь оригинала: вызывающая сторона сама передаёт
время, а локальный генератор Rust пока учитывает только случайные числа
восстановленного стартового шага и ресурсного планировщика.
Откуда берутся объекты распределителя
Для каждого клана Iron3D создаёт AI независимо от того, найден ли его .scr.
Таблица GetSuperAI получает указатель только для клана, чей пакет сценария
успешно загружен. У размещённого объекта первое DWORD после имени ресурса
выбирает индекс этой таблицы; второй соседний DWORD для этой цели не используется.
World3D получает индекс через дескриптор создания объекта и передаёт его в
Behavior. Отдельно DWORD по смещению +4 от начала Unit DAT задаёт class word
объекта. Это второе 32-битное слово файла; его старший бит приводит к созданию
registration key, который проходит защиту Behavior вместе с проверками AI и
distributor.
Для обычной полной загрузки Rust сохраняет по одной записи состояния на клан.
Каждый размещённый объект с известным Unit DAT и установленным старшим битом
получает существующий ObjectHandle и попадает в список только при точном
совпадении индекса raw_after_resource с доступной записью GetSuperAI. Индекс
не перенумеровывается: например, пакет клана 1 остаётся слотом 1 даже если
пакет клана 0 отсутствует. Preview сохраняет свои статические данные, но не
создаёт эти связи.
В лицензированной AutoDemo это даёт четыре членства: TMA объекты 4 и 6 идут
в слот 0, а объекты 5 и 7 — в слот 1. У парных записей первого клана raw
селектор равен 0, у второго — 1; классы из Unit DAT равны 0x80000010 для
wor_l_n1.dat и 0x80000002 для gener01.dat. Тот же обычный путь в исходном
Behavior добавил четыре ключа 0x80000003..0x80000006 в два distributor-а.
Rust-тест повторно читает лицензированную миссию, связывает результаты с её
TMA-записями и проверяет реальные ObjectHandle, но помечен #[ignore], чтобы
не требовать оригинальных ресурсов от остальных разработчиков.
Список распределителя хранит не только участников, но и долю каждого. При добавлении двух объектов оба получают по половине общего веса. Если добавить третий, движок оставляет долю новой записи около одной трети, а оставшийся вес делит между прежними изменяемыми записями. Помеченная запись сохраняет свой вес, как закреплённая часть общего набора. Повторное добавление того же объекта не создаёт вторую строку и оставляет остальные слова записи нетронутыми.
Rust хранит этот небольшой начальный список с долями. Он не смешивает selector
из TMA, владельца мирового объекта и внутренний ключ Behavior: связь в Rust
опирается на уже созданный ObjectHandle. Регулярный обмен ресурсами пока
остаётся отдельным этапом, потому что для него нужны интерфейсы игровых объектов.
Начальные свойства запасов руды
Behavior хранит CurrentOre и MaximumOre в одной записи свойства с ключом
0x02000100: текущее количество лежит в основном float-слове, предел — в
дополнительном DWORD. В TMA это не означает, что оба значения свободно задаются
файлом. При загрузке MisLoad.dll ищет первое свойство с точным, чувствительным
к регистру совпадением имени и передаёт его второе raw-слово в setter. В
Behavior дескриптор CurrentOre записывает это слово в основное значение;
дескриптор MaximumOre читает дополнительное слово, но его setter ничего не
меняет.
Оба дескриптора регистрируются как коды 4 и 5. Это коды обработчика, а не
позиции в таблице дескрипторов: исходная DLL корректно работает и после
перестановки строк, потому что callback выбирает обработчик по коду строки.
Поэтому начальное состояние можно описать так, сохраняя биты без пересчёта
через float:
current = first_exact("CurrentOre").raw_value[1] ?? class_default_current
maximum = class_default_maximum
Класс задаёт значения по умолчанию в Behavior RVA 0x8790:
| Слово класса | CurrentOre |
MaximumOre |
|---|---|---|
0x80000004 |
0 | 500 |
0x80000008 |
0 | 4000 |
0x01002000 |
0 | 2000 |
0x01004000 |
2000 | 2000 |
| прочие слова в этой ветви | 0 | 0 |
Вызов RVA 0x8790 на синтетической структуре подтвердил эти значения для
классов таблицы, включая 0x01008000; он проверяет тело native initializer-а,
но не его полный жизненный цикл в игре.
В полной загрузке Rust сохраняет эти два raw-значения рядом с исходным TMA ID
и ObjectHandle каждого объекта с Unit DAT; список участников распределителя
для этого не требуется. Это важно для объектов, у которых есть компонент
Behavior, но нет членства в ресурсном distributor. Preview намеренно не создаёт
начальное состояние. Лицензированная AutoDemo показывает восемь объектов с
Unit DAT, включая объекты класса 0x01008000 вне списков distributor; в этом
снимке TMA CurrentOre равен нулю у всех восьми. Нативный x86 oracle отдельно
проверяет ненулевое raw-слово, чтение предела, отсутствие записи в MaximumOre
и переставленные строки дескрипторов.
Эта модель покрывает именно начальную загрузку полных TMA-объектов. Она не утверждает, что уже восстановлены все пути создания Behavior, отложенное создание объектов или внешняя подмена class defaults конфигурацией.
Регулярная передача ресурсов
Resource Distributor работает со списком зарегистрированных объектов. За один шаг он сначала проходит список и собирает нужные значения, а затем проходит его ещё раз и применяет результат. Во втором проходе объект ищется заново, поэтому его состояние или ответ getter-а может измениться между чтением и записью. Нулевой временной шаг ничего не делает; некоторые состояния объекта также пропускают строку ещё до запросов ресурсных интерфейсов.
В синтетическом примере исходного кода два объекта с ключами 41 и 42
обрабатываются в порядке 41, 42, 41, 42: производитель сначала сообщает
скорость 4, затем 1, а получатель со значением 6 заканчивает шаг со
значением 7. Это показывает, почему одного результата чтения недостаточно:
алгоритм снова получает величины при применении. Сценарий был запущен прямо из
исходной Behavior.dll, но объекты и ответы интерфейсов заданы тестом; это ещё
не воспроизведение реальной миссии.
Для правила долей есть отдельная проверка исходной Behavior.dll на синтетических
записях при игровом x87 control word 0x007f: проверены добавление, повторный
ключ, закреплённая запись, нулевая сумма и округление. Это позволяет Rust
сохранять начальный список, но пока не подтверждает веса реальных объектов в
живой миссии.
Узкая часть регулярного шага уже связана с загруженным состоянием движка.
advance_loaded_mission_resources(engine, queue_time_ms) принимает явную
метку времени общей 32-битной очереди. Для каждого видимого Behavior clan он
проверяет срок запуска, выполняет два прохода по списку и сохраняет пять
итогов: доступную энергию и руду, буфер руды, потребление энергии и потребление
руды. В обоих проходах объекты разрешаются заново по исходному TMA ID. Первый
проход измеряет поставки и потребность, второй повторно читает представленные
профильные и Control-значения перед применением выделения. Нативный код также
запрашивает интерфейсы во втором проходе, но динамические getter-callbacks в
этом снимке Rust не воспроизводятся.
Планировщик использует беззнаковое сравнение времени: равенство ненулевому
сроку ещё не запускает шаг, а нулевой срок не задерживает его. Переполнение
вычитания даёт 32-битную длительность. Затем
длительность переводится в секунды с округлением, подтверждённым x87-путём
оригинальной DLL. Нативный oracle проверяет его на длинных интервалах, включая
значения около границы точного целого f32, а также на равенстве и строгом
проходе срока. Формула запуска, native vectors и команда воспроизведения
описаны в технической главе script VM.
Шаг работает только с уже созданными полными объектами миссии и снимками тех
профилей и Control-таблиц, чьи запросы и setters представлены в runtime. Он
обновляет загруженные CurrentOre, поля строк распределителя и поддержанный
Control selector 1; неподдержанное свойство, запрос Control или невозможное
арифметическое значение возвращает ошибку. Обновления нескольких кланов, их
расписания и RNG фиксируются одной транзакцией: если обязательный запрос
позднего клана не разрешён, ранее подготовленные изменения остаются
неприменёнными. Обычные step_headless(engine, input, queue_time_ms) и
frame(engine, queue_time_ms) запускают этот расчёт до обработки очереди мира.
Они сначала готовят ресурсные изменения, затем выполняют шаг мира и публикуют
ресурсное состояние после его успеха. Ошибка любой стадии сохраняет прежние
ресурсы; ошибка подготовки также не продвигает мировой tick. До срока
планировщика большие таблицы ресурсов не копируются.
Оконное приложение пока запускает static preview и не вызывает этот расчётный путь. Динамические callbacks, которые переносят количество между Control provider-строками, ещё не моделируются. Поэтому это исполняемый загруженный resource step, а не полный симулятор миссии.
Для настоящих ресурсов AutoDemo тест проходит несколько временных точек:
генераторы ObjectId 6 и ObjectId 7 читают Transfer_Power_Out = 10 из
prof_generator.var; после шага в 500 мс каждый сообщает 5 единиц доступной
энергии. Это подтверждает связь загруженного профиля с runtime-расчётом, но не
сравнение полной миссии с оригинальной игрой. Native oracle сверяет исходные
планировщик и распределитель GOG Behavior.dll; техническая процедура и
проверяемые векторы описаны в script VM. Остальные ограничения собраны в
границах знания.
Первый часто встречающийся table entry с side effect — Handler(2) (176
records в corpus). Он берёт active instruction, разрешает семь slots через
varset, приводит три значения к float по observed kinds 5/3, затем
materializes или refresh-ит внутренний event record. Его base string связывает
<base>_Start и <base>_Continue с event table; прямого World3D/Behavior
call на этой ветке не найдено. Slot names и consumer record ещё не установлены
динамически, поэтому Rust не исполняет handler как гипотетическую команду
движения/атаки/строительства.
Безопасная runtime-модель:
load script bundle
-> validate version and symbol tables
-> create global/formula variables
-> bind named events to instruction offsets
-> instantiate SuperAI per clan
-> dispatch MISSION_START and object events
-> update timers/conditions each simulation tick
-> enqueue game commands through World3D/Behavior
Сценарий не должен владеть игровым объектом напрямую. Он хранит logical/object IDs и отправляет команды через игровые interfaces, чтобы удаление объекта или сетевой mirror не оставили dangling pointer.
Полная grammar compiled instructions и точное значение всех opcodes остаются
открытым направлением. До появления decompiler-а .scr binary body сохраняется
lossless, а доказанные symbol/event tables документируются отдельно.
Подтверждённый evaluator записей условий
У сценарного слоя есть не только последовательность инструкций, но и отдельные
короткие проверки состояния. В GOG ai.dll версии 1.00 evaluator таких
проверок расположен по RVA 0x5180 (VA 0x10005180 при image base
0x10000000; SHA-256 сборки —
c19cd6cd31cd2896f8c4ba0f0e700dd3408aec8182daaf4863d98a7d233a2abc). Он
получает индекс 16-byte записи и смотрит на её tag, subtype и payload. Для tags
1–3 допустим subtype 0, для tags 4–5 — subtype 1; другие сочетания не
завершают условие. Байт по смещению +0x0c — флаг завершения: evaluator ставит
1, когда проверка проходит, и оставляет запись как есть, когда не проходит.
Вызывающая функция позже пропускает уже завершённые условия.
Tag выбирает конкретное правило. Для tag 1 запись завершается, когда поиск
объекта по payload ничего не нашёл. Для tag 2 объект, наоборот, должен
существовать: затем игра проверяет одно из его свойств и, в более сложной
ветви, принимает только три числовых кода результата — 1, 0x12 и 0x13.
Для tag 3 найденное значение сравнивается с первым числом текущего контекста.
Мы не знаем из одного этого кода, каким игровым словам соответствуют эти поля,
поэтому не называем их владельцем, жизнью или приказом.
Теги 4 и 5 обходят дочерние ссылки выбранного списка. Значение -1 означает
«элемента нет» и пропускается. Тег 4 завершается, только если ни один дочерний
ключ не нашёл объект. Тег 5 тоже пропускает отсутствующий объект, но для
найденного смотрит результат другого виртуального вызова: ноль разрешает
продолжить, а первый ненулевой результат прекращает обход и оставляет условие
незавершённым. Так различаются два алгоритма проверки набора объектов без
предположений о том, почему сценарист выбрал один из них.
У списков обнаружена нетипичная граница. Программа сначала сравнивает индекс списка с его длиной как unsigned число и отбрасывает только значения строго больше длины. Равный длине индекс проходит первую проверку, но следующий проверенный доступ обнаруживает выход за границу и возбуждает C++ exception. Поэтому в будущем движке это нельзя незаметно заменить на «все неверные индексы просто дают ложь».
Это статическая реконструкция ветвей, а не доказательство исполнения условия
в работающем оригинале. Сам вызов RVA 0x5180 и внешний object interface не
проверялись динамически; неизвестен и код, который создаёт эти 16-byte записи.
Считыватель .scr хранит описатели событий и вложенных записей во внутренних
контейнерах с шагом 0x14 и 0x20 соответственно. На диске же .scr использует
переменные поля: имя события задаётся длиной, а вложенная запись содержит
заголовок и список ссылок. Связь этих структур с evaluator-ом не доказана.
Подробные смещения,
виртуальные слоты и границы анализа приведены в
приложении о сценарной VM
и границах знаний.
Исход миссии и состояние игры
Сообщение сценария об успехе или провале проходит ещё один рубеж — обратный
вызов в игровое приложение. У этой границы есть собственные правила: в режиме
отдельной миссии вызов может записать новый исход, а в автоматической демо-игре
та же ветвь подавляется. Повторный сигнал не обязан исправлять соседний флаг
или сохранённый Rust-исход: оригинал решает по своему слову состояния, а не по
сравнению всех полей между собой. Поэтому загрузка карты не означает, что игра
уже перешла в активный игровой цикл; это отдельный шаг запуска. Технические
смещения, начальное состояние и граница проверки описаны в
приложении о сценарной VM.
TRF и preload-данные
TRF-файлы проходят структурный разбор. auto.trf, data.trf и tutorial
variants имеют сигнатуру NRes и содержат большие
таблицы имён игровых прототипов: оружия, башен, сооружений и других объектов.
Также найдены preload-записи, ANI и SKE resources.
По содержимому, порядку загрузки и consumers TRF с высокой вероятностью предоставляет AI/сценарному слою заранее подготовленную таблицу типов и связанных данных. Framing и имена подтверждены corpus-ом, но полная семантика каждой TRF-записи ещё не закрыта. Имена должны разрешаться через тот же resource registry, что и миссионные объекты.
Стабильность AI-слоя
ai.dll, Behavior.dll и Wizard.dll побайтно идентичны в Частях 1 и 2. Это
подтверждает, что разделение SuperAI -> Behavior -> Wizard и бинарная
реализация этих трёх уровней не менялись.
Сценарный корпус:
Часть 1: 58 SCR, 58 FML, 29 TRF
Часть 2: 59 SCR, 59 FML, 44 TRF
Все TRF являются структурно валидными NRes. Неизменность DLL усиливает вывод о
стабильной VM, но не закрывает instruction grammar .scr: для неё нужен
dispatcher/jump-table decompiler. Дополнительные сценарные данные расширяют
differential corpus, но не заменяют анализ VM.
Control, физика и коллизии
Control превращает желаемое движение в физически допустимое изменение состояния. World3D владеет жизненным циклом объекта; Terrain предоставляет поверхность и world queries; Behavior/Wizard задают намерение; Control создаёт physical controller и collision representation.
Публичная поверхность:
InitializeSettings
LoadControlSystem
LoadPhysicalModel
CreateCollManager
CreateCollObject
Модуль импортирует World3D queue/object functions, Terrain::GetWorld, часы,
тригонометрию и g_FastProc. Это подтверждает его положение между gameplay
object и геометрией мира.
Статическая сверка GOG Control.dll уточняет границу, но пока не раскрывает
per-tick solver. PE32 image base — 0x10000000; exports имеют следующие RVA:
InitializeSettings 0x32260
LoadControlSystem 0x32280
LoadPhysicalModel 0x32580
CreateCollManager 0x325d0
CreateCollObject 0x32600
LoadControlSystem возвращается ret 0x20, следовательно ABI снимает со
стека восемь 32-bit аргументов. Оно выбирает allocation размером 0x668 при
mode 9 и 0x670 для другого mode, создаёт внутренний reader через virtual
dispatch с selector 10, переносит несколько caller strings в локальные
buffers и передаёт собранную settings-структуру дальше через virtual slot
+0x08 с key 0x80000020. Это доказывает loader/configuration boundary и
два layout variants, но не даёт права назвать поля скоростью или acceleration.
LoadPhysicalModel аналогично создаёт 0xa0-byte reader и возвращается
ret 0x0c; CreateCollManager и CreateCollObject возвращают interface
pointer с поправкой +4 после внутренней инициализации.
Инструкциям x86 в GOG Control.dll (+0x32280) и caller-у в AniMesh.dll
(+0x32e7) соответствует ABI LoadControlSystem(char*, char*, char*, char*, char*, char*, void*, i32): шесть C-string pointers, resource context и mode.
Функция заканчивается ret 0x20, то есть снимает все восемь 32-bit stack
arguments; arg7 передаётся constructor-у как context, arg8 выбирает mode.
Прежняя декомпиляция ошибочно определяла шестой строковый аргумент как u32.
Остальные exports имеют формы
LoadPhysicalModel(u32, u32, u32), CreateCollManager(u32) и
CreateCollObject(u32, u32). InitializeSettings получает
CreateGameSettings() из World3D и делает virtual call slot +0x24 с literal
0x15 и строкой по RVA 0x42478.
Именно update methods этих private objects, а не пять exports, остаются следующим объектом динамической трассировки. Поэтому reference movement в новом runtime намеренно не использует неподтверждённые параметры Control.
Связь с AniMesh
PE import table GOG AniMesh.dll показывает, что это прямой consumer
CreateCollManager, CreateCollObject и LoadControlSystem; других DLL,
которые статически импортируют эти три named exports, не найдено. Внутренний
AniMesh path по VA 0x100032e7 вызывает Control thunks в строгой наблюдаемой
последовательности:
LoadControlSystem (thunk 0x1001934e)
-> CreateCollObject (thunk 0x10019348)
-> CreateCollManager (thunk 0x10019342)
После factory calls caller immediately берёт returned interface vtable и
делает дальнейшие virtual calls; это связывает Control с загрузкой
AniMesh/unit components, а не с одной глобальной настройкой процесса. В
частности, CreateCollObject получает два stack arguments, а
CreateCollManager — один. Предметные значения этих arguments, ownership
private objects и per-tick update slots пока не восстановлены; порядок
создания не доказывает скорость, collision algorithm или AI semantics.
Headless Ghidra call-site decompile уточняет configuration provenance:
AniMesh передаёт в LoadControlSystem шесть 32-byte strings из одного
configuration block по offsets +0x80, +0xa0, +0xc0, +0xe0, +0x100 и
+0x120, затем resource context и mode из owner +0x6d8. Returned Control
interface сразу получает пять virtual calls, связывающих его с owner slots
+0x158, +0x160, +0x164, +0x168 и +0x18c; collision object затем
связывается с +0x170. Это достаточное основание хранить будущий Control
component как ordered raw-string/resource provenance, но не для присвоения
этим строкам смысловых имён до трассировки private update methods.
Runtime сохраняет ordered raw Unit DAT records рядом с каждым mission object
draft. Теперь подтверждена связь Unit DAT с descriptor-ом AniMesh, который
передаётся в Control: порядок узлов задаётся деревом immediate-child counts,
а parent_or_link попадает в descriptor как локальное поле. Его смысл зависит
от registry type и состояния Control manager-а; в частности, для INTO это
локальный индекс устройства, а EXTO проходит отдельную ветвь.
От Unit DAT к Control descriptor
В GOG ArealMap.dll (SHA-256
a25f9254ebb339d70e2e07c8121950191e9584ca0858d8e9c2e2b6c671edf6ea)
FUN_10016a10 (RVA 0x16a10) читает запись Unit DAT в дерево узлов по
0x78 байт: перед сырыми полями узла находятся указатель и количество детей,
а байты файла копируются начиная с node +0x0c. FUN_10015290 (RVA
0x15290) передаёт каждый непосредственный узел корня в FUN_10014b90
(RVA 0x14b90) с начальным parent ID 0.
Для узла с ненулевым raw kind FUN_10014b90 формирует descriptor размером
0x4c: две строки ресурса копируются из node +0x0c и +0x2c, поле
descriptor +0x40 получает parent ID, +0x44 — DWORD node +0x50, а
+0x48 начинается со значения -1. Из-за смещения raw-полей на 0x0c
node +0x50 — это ровно parent_or_link по смещению 0x44 в записи Unit
DAT. Затем ArealMap посылает owner-у событие 6 / 0x80000020 через vtable
slot +0x34.
В AniMesh.dll (SHA-256
65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e)
dispatcher FUN_10001370 (RVA 0x1370) передаёт это событие в
FUN_10003760 (RVA 0x3760). Тот выбирает следующий свободный ID, записывает
его в descriptor +0x48 и сохраняет descriptor в списке компонентов.
Вернувшись после virtual call, FUN_10014b90 передаёт этот ID как parent ID
рекурсивного обхода детей. Поэтому родительская связь Control descriptor-а
строится по фактически назначенному ID в обходе дерева, а локальный индекс
устройства приходит из parent_or_link.
У этого обхода есть ранний выход: если raw kind в node +0x4c равен нулю,
узел и его дети пропускаются. AniMesh начинает со служебного root ID 0, но
из-за пропусков и других записей назначенный ID нельзя в общем случае считать
равным индексу узла или номеру его записи в Unit DAT.
Для registry type INTO и других не-EXTO значений FUN_10003760 разрешает
parent ID через Control IID 0x202, virtual slot +0x44, только если parent ID
не равен -1 и интерфейс IID 0x202 доступен. Control.dll
FUN_1002ecc0 (RVA 0x2ecc0) возвращает индекс первого устройства, чьё поле
+0x08 равно этому ID, или -1, если совпадения нет; AniMesh прибавляет
возвращённое значение к локальному индексу +0x44 без отдельной проверки
-1. Если parent ID равен -1 или IID 0x202 недоступен, используется сам
локальный индекс без смещения. Отсутствие устройства с parent ID — отдельный
случай: native path даёт индекс на единицу меньше локального, и Rust-проекции
нужно проверить это поведение явно. EXTO проходит отдельную ветвь, поэтому
это правило нельзя распространять на все registry types.
Ветка EXTO заполняет другие поля. В FUN_10003760 RVA 0x3760 и mount
C4, и target C8 сначала равны -1. Ветка EXTO вычисляет mount и
записывает его только в C4; C8 остаётся -1. Поэтому Control добавляет
все typed records такого компонента в конец общей таблицы. Raw
parent_or_link здесь выбирает mount-узел, а не строку Control.
AniMesh.dll (SHA-256
65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e)
собирает глобальную нумерацию MSH-узлов: обычная корневая модель добавляет все
N узлов, EXTO-дочерняя модель добавляет N - 1, начиная со своего узла 1,
а INTO-компонент не добавляет узлов. FUN_1000a460 (RVA 0xa460) выполняет
эту загрузку MSH; FUN_10005780 (RVA 0x5780) находит первый глобальный узел
с заданным component ID. Если у EXTO-модели только один узел, она не добавляет
глобальных узлов, и поиск первого узла возвращает -1.
При добавлении EXTO Control отдельно переводит ключи CTLD в пространство
глобальной MSH-иерархии: нулевой ключ заменяется mount-узлом C4; ненулевой
ключ сдвигается относительно первого глобального узла компонента только при
C4 != -1. Например, если компонент начинается с глобального узла 22, его
исходный ключ 1 становится 22. Этот ключ выбирает модельный узел, а не
строку Control. Записи корня остаются в исходном виде.
Как Control добавляет и переопределяет устройства
В Control.dll (SHA-256
bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e)
FUN_10008b10 (RVA 0x8b10) выбирает путь по полю descriptor +0xc8.
Значение -1 запускает цикл по typed records; фабрика FUN_1002d4b0 (RVA
0x2d4b0) создаёт по устройству на запись и сохраняет их порядок. Иное
значение вызывает FUN_1002d890 (RVA 0x2d890) только один раз; цикл по
typed-record count в этой ветви отсутствует. Следовательно, подтверждённый
override path применяет первую typed record и не добавляет новый объект.
Так модуль меняет характеристики уже существующего слота, не увеличивая число
устройств. Неизвестный tag в Rust CTLD parser приводит к Unsupported и может
оставить typed-record list неполным. Native factory имеет отдельный default
путь 0x20800, поэтому такой результат парсера сам по себе не доказывает отказ
оригинального loader-а и не позволяет объявить Rust merge полным.
FParkan строит базовую проекцию этой таблицы во время полного mission load для
объектов с Unit DAT. Корневые typed records добавляются в исходном порядке;
EXTO тоже добавляет все свои записи с remapped MSH-ключами; INTO обновляет
одну строку по C8, а при sentinel -1 добавляет весь список. Preview таблицу
не создаёт. Проекция хранит исходный factory tag, текущий typed record и их
provenance, но не исполняет все tag-specific callbacks и не заменяет полный
Control runtime. Лицензированный UNITS/BUILDS/MINE/min_l_n1.dat подтверждает
10 исходных устройств, четыре INTO-override по индексам 0..3 и добавление
одной дочерней записи EXTO с ключом 22, всего 11 устройств. Это результат
Rust full-loader теста на оригинальных ресурсах. Ветви C4/C8 и добавление
EXTO подтверждены статически по native ASM; отдельный native full-load oracle
для всей таблицы не запускался.
Для tag 19 фабрика создаёт объект с его исходными классом и vtable. Базовый
конструктор FUN_100218b0 задаёт multiplier +0x4c = 1.0. Override через
FUN_1002d890 вызывает у выбранного объекта vtable slot +0x1c, то есть
FUN_10021d50 с флагом инициализации 0. Этот вызов не создаёт объект и не
заменяет класс или vtable. Он сохраняет уже существующий ключ +0x04; сохраняет
текущее значение multiplier +0x4c (которое могло измениться после
конструктора); и копирует коэффициенты из CTLD в блок +0x54. Поле +0x50
обновляется лишь когда слово записи +0x18 не равно -1. Сопоставленные
записи значений прибавляются к текущим полям объекта +0x1c/+0x20, а не
сбрасывают их. Поэтому «переопределение» здесь означает обновить часть данных
того же устройства, с ограничениями конкретного метода.
Native x86 oracle для тех же GOG DLL проверяет переопределение получателей
factory tags 2, 3, 4, 5, 8, 9, 10, 15, 19, 21, 25 и 27.
Тест подтверждает сохранение существующего слота, класса/vtable, ключа,
component ID и multiplier 2.5 при копировании новых коэффициентов. Для новых
получателей конструкторы и начальное слово +0x50 связаны с такими RVA:
| Factory tag | Конструктор | Vtable | Начальное +0x50 |
|---|---|---|---|
2 |
0x294c0 |
0x3cbd8 |
0 |
3, 21 |
0x20800 |
0x3c448 |
5 |
4 |
0x233d0 |
0x3c720 |
0x200 |
5 |
0x20800, затем замена vtable фабрикой |
0x3cdf8 |
5 |
8 |
0x24310 |
0x3c800 |
5 |
10 |
0x26470 |
0x3ca20 |
0x2000 |
27 |
0x218b0 |
0x3c528 |
0 |
У всех строк таблицы multiplier начинается с 1.0. Tags 3 и 5 проверены
через настоящую фабрику 0x2d4b0; tag 3 попадает в её default-ветвь.
Общий reader 0x21d50 сохраняет переменный хвост: две map-строки 7 и 9
и дополнительная строка fx возвращают cursor record + 0xbf.
Слово CTLD +0x18 == -1 сохраняет фабричное +0x50 и при INTO-замене.
У tag 2 slot +0x1c указывает на обёртку 0x29650. Она вызывает тот же
reader, затем сохраняет selector 1 в +0x120, округляет selector 0 через
x87 FISTP в +0x124 и заново обнуляет массив +0x12c по накопленному числу
map-строк. Native тест получает 1.25, округление 3.5 → 4 и четыре нулевых
элемента после повторного override. Rust хранит общую проекцию устройства;
эти оружейные кэши и дальнейшая стрельба в неё пока не входят. Slot +0x24
этого класса (0x29940) проверен только с неизменной пустой парой ресурсов.
Tag 9 проверяется без FX-хоста (+0x3c == 0) и без map-строк; его FX-путь
этим опытом также не подтверждён.
Полный Rust loader на исходном MISSIONS/Autodemo.00/data.tma строит доступные
таблицы для всех восьми объектов: четыре робота содержат 14, 12, 15, 13
устройств, затем два завода по 12 и два генератора по 11. Это проверка
подготовки таблиц на настоящих ресурсах, а не сравнение полного игрового Control
с запущенным оригиналом.
Статический разбор глобального Control interface показывает aggregate getters
1/2 (FUN_1002b410, RVA 0x2b410) и setter 1 (FUN_1002ba30, RVA
0x2ba30) для текущего descriptor tag 19. Getter 1 складывает ответы
виртуального selector 0x200 в числитель и selector 0 в знаменатель; getter
2 возвращает сумму selector 0. Для обычного factory tag 19 эти ответы
равны coefficient * multiplier и coefficient, то есть отношение сводится к
sum(coefficient * multiplier) / sum(coefficient). У factory tag 9 selector
0 умножает коэффициент на отдельное поле +0x11c, тогда как selector 0x200
использует коэффициент и multiplier; эта особая база меняет знаменатель.
Обработчик factory tag 9 расположен в Control RVA 0x255c0, а его конструктор
RVA 0x25460 задаёт +0x11c = 1.0. При встрече отрицательного ответа selector
0 getter 1 сразу возвращает 1.0, а getter 2 — этот ответ. Если подходящих
записей нет или сумма базы неположительна, getter возвращает неуспех без записи
результата. Setter пишет multiplier +0x4c только устройствам с текущим
descriptor tag 19 и положительным selector-0 ответом; пустой набор возвращает
успех. Фильтр использует текущий descriptor tag, который может измениться
при INTO, а виртуальный вызов остаётся методом исходного factory-класса.
Этот путь проверяется native x86 oracle на оригинальной Control.dll указанной
выше: искусственные factory/descriptor пары 9, 15 и 19 покрывают setter,
отрицательные ответы, округление и тег 9 с scale 0.5. Для factory tag 25
его 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.
Отдельный native setter selector 17, tag-specific методы прочих factory tags,
их внешние эффекты и вызов из регулярного шага Behavior здесь не моделируются.
Числа должны оставаться конечными и нормальными либо точным нулём; произведения
и отношения за поддерживаемым диапазоном f32 возвращают явную ошибку, а не
подменяются нулём или None. При проверке диапазона Rust оценивает операцию в
f64, затем выполняет её в f32; это ограниченная проверка диапазона, а не
эмуляция полного x87 accumulator-а.
Отдельная ветка того же GOG AniMesh.dll показывает, когда создаётся Behavior
для host object. В FUN_10002ff0 (RVA 0x2ff0, SHA-256
65659bf67f20e5be77d67a5b2f59968f61b94293143807fa59ca451c560c4f7e) вызывающая
сторона запрашивает IID 0x204 и сохраняет интерфейс в поле +0x168. Затем
отдельный импорт GetQueue() возвращает объект очереди; вызов метода через его
vtable slot +0x30 даёт результат, который проверяется на ненулевое значение.
Только при ненулевом результате host class 3 вызывает
CreateBehaviour(0, 0, self) (RVA 0x34bd); class 4 передаёт в
CreateBehaviour адреса полей prototype +0x140 и +0x160 (RVA 0x3426).
Семантика метода slot +0x30 здесь не установлена. Это подтверждает только
такую ветку и эти два класса; другие фабрики, нулевой результат и отложенные
spawn сюда не входят.
Один запрос Control для ресурсного устройства
В CTLD запись tag 26 задаёт ресурсный множитель, но текущее значение
поставщика живёт в runtime state Control. GOG Control.dll (SHA-256
bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e)
FUN_10021d00 (RVA 0x21d00) реализует getter selector 0x300. Его фиксированное
слово +0x2c становится fixed_words[11] при копировании записи RVA 0x21d50;
fixed_words[1] — ключ строки NDP. В объекте Control getter использует ключ
+0x04, provider interface +0x44, множитель +0x4c и коэффициент +0x54.
Provider interface направляет selector 1 и ключ в Control RVA 0xdc40, где
читается текущее type-1 значение выбранной строки. Затем selector умножает
сначала provider на коэффициент, после чего на множитель: provider * base * multiplier. Коэффициент приходит из CTLD; NDP связывает ключ с описанной
строкой. Native loader создаёт для этой строки отдельное runtime-состояние: у
Control.dll (та же сборка, SHA-256
bcb86318089028548a178b03a21accdce15df0cc85ddd91674cf3c2f55012c6e)
FUN_1000b240 (RVA 0xb240) строит 11 слов строки provider и ставит её
selector-1 поле +0x14 в 1.0. FUN_10008b10 (RVA 0x8b10) выбирает строки
NDP, а FUN_1000a100 (RVA 0xa100) добавляет их в общую таблицу с шагом
0x2c; getter FUN_1000dc40 (RVA 0xdc40) использует ключ устройства как
индекс этой таблицы. Для корня загружаются все строки. INTO не добавляет
строк, а примонтированный EXTO пропускает свою первую строку. Пустая
таблица такого EXTO не содержит строки, которую native loader должен
пропустить, поэтому Rust оставляет provider lookup недоступным; sidecar с одной
строкой даёт пустой вклад. Если mount узел не разрешился, строки этого EXTO
в таблицу не попадают. Это отдельная
последовательность provider-строк: её длина берётся из NDP, она не дополняется
до числа узлов MSH и не участвует во второй перенумерации ключа.
Так этот селектор включён в цепочку добычи руды оригинала. В той же сборке
Behavior.dll (SHA-256
ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99)
FUN_100186b0 (RVA 0x186b0) перечисляет устройства типа 0x1a, а
FUN_100198e0 (RVA 0x198e0) суммирует успешные запросы IID 0x204 с selector
0xe00. В парной Control.dll выше FUN_1002bb40 (RVA 0x2bb40) для типа
0x1a вызывает selector 0x300, описанный в этом разделе. Затем Behavior query
0x1002 (RVA 0xa490) умножает полученную сумму на поле +0x58
Transfer_Ore_OffBoard блока +0x820; отдельный selector 0x1005 возвращает
+0x60 Transfer_Power_Out. Имена регистрируются в RVA 0x22e20, а конструктор
RVA 0x22d90 задаёт начальные значения 5.0 и 100.0. Восемнадцать точных class
codes выбирают записи .var из behpsp.res: например, 0x80000004 выбирает
prof_mine.var, 0x80000002 — prof_generator.var, а три кода bunker-класса
выбирают prof_bunker.var. Таблицу этих соответствий задаёт mapper
FUN_10008a80 (RVA 0x8a80) в Behavior.dll SHA-256
ddfbdbde07832358fe6e35e01c8dba9b58e7313789498b6049ed51d4ed9baf99.
Установленные соответствия mapper-а:
| Class flags | Запись behpsp.res |
|---|---|
0x80000004 |
prof_mine.var |
0x80000008 |
prof_storage.var |
0x80000002 |
prof_generator.var |
0x80000010 |
prof_plant.var |
0x80000040 |
prof_angar.var |
0x80000200 |
prof_mtp.var |
0x80000400 |
prof_institute.var |
0x80010000, 0x80020000, 0x80040000 |
prof_bunker.var |
0x80100000, 0x80200000 |
prof_tower.var |
0x01002000 |
prof_trn.var |
0x01004000 |
prof_bld.var |
0x01008000 |
prof_war.var |
0x01010000 |
prof_hq.var |
0x01020000 |
prof_hero.var |
0x20000000 |
prof_animal.var |
У бинарной строки .var есть имя, индекс типа, размер и две области данных.
Native parser RVA 0x49fa0 читает запись, binder RVA 0x49090 связывает её с
зарегистрированным полем, а apply-loop RVA 0x49170 копирует в поле именно
вторую область. В профиле используются точные C-string имена
Transfer_Ore_OffBoard и Transfer_Power_Out; регистр учитывается, первый
совпавший ряд побеждает. Rust ограничивает чтение исходной записи NRes её
границами и сохраняет значения как исходные 32-битные слова у соответствующего
ObjectHandle. Разбор профиля для полной миссии завершается до создания нового
мира: отсутствие или повреждение профиля известного класса прерывает загрузку,
не подменяя предыдущую миссию. Если mapper не знает class code, остаются
конструкторские значения 5.0 и 100.0; это не догадка о неизвестном профиле.
Preview не загружает профили.
При вычислении 0x1002 native FUN_100186b0 (RVA 0x186b0) добавляет в список
только текущие Control descriptor-и tag 26, после чего
FUN_100198e0 (RVA 0x198e0) суммирует их успешные 0xe00 запросы. FParkan
делает то же для загруженной таблицы в её исходном порядке и затем умножает
сумму на профильный Transfer_Ore_OffBoard. Записи tag 5, 10 и 21 в общей
таблице в этот список не попадают. Запрос 0xe00 проходит по ключу устройства
в исходную provider-таблицу и пока поддержан только для текущего descriptor
tag 26 с factory tag 26; для него результат равен
provider * fixed_words[11] * multiplier. Лицензированный тест загружает
оригинальные CTL/NDP и полную миссию с этим Unit DAT. В установленном
min_l_n1.dat class word равен 0x80000004, поэтому исходный прогон выбирает
prof_mine.var. Для проверки другого отображения второй прогон оставляет тот же
TMA и компоненты, но во временном VFS заменяет слово класса заголовка DAT на
0x80000002; это тестовая подмена, а не содержимое лицензированного файла.
Профиль mine даёт Transfer_Power_Out 0.0 и Transfer_Ore_OffBoard 1.0;
оригинальный профиль генератора — 10.0 и 0.0 соответственно. Для исходного
tag-26 ряда получается 1.0 provider × 1.0 coefficient × 1.0 multiplier;
профиль mine умножает эту сумму на 1.0, поэтому selector 0x1002 возвращает
1.0. Эти числа описывают начальное состояние после конструкторов.
Сами запросы читают текущую долю provider; отдельное прямое изменение строки
уже может изменить ответ, хотя полный игровой Control tick ещё не исполняется.
У Control есть и начальные сведения о модели узла, но их таблица не совпадает
с NDP. Sidecar задаёт состав и порядок provider-строк, а поле type-1 +0x14
получает начальное значение 1.0 от нативного конструктора. AniMesh отдельно
собирает глобальный список MSH-узлов. При создании строки Control берёт
mesh-состояние по её порядковому номеру в общей таблице provider, а не пытается
угадать пару по локальному номеру строки sidecar-а. Например, после 22
корневых строк номер 22 указывает на первый глобальный узел присоединённой
EXTO-модели, который
происходит из её локального узла 1.
В снимке это необязательное поле: None означает, что для данного номера не
удалось получить MSH-узел; исходные запросы к NDP от этого не исчезают. Поэтому
начальное значение и поза модели остаются отдельными частями состояния:
initial_mesh_state: Option<MissionControlNodeInitialMeshState>
Внутри снимка хранятся родительский индекс, переведённые флаги Control и число
начальных стадий. Узлы корневой модели добавляются все, а присоединённая EXTO
начинает с локального узла 1. Это описывает исходную настройку. Отдельный
API уже пересчитывает стадию одного узла, но полный игровой Control tick
пока не исполняется.
На той же начальной стадии Control вычисляет количество строки по NDP
max_volume. Для загрузки Unit DAT проверенные множители равны 1.0, поэтому
количество совпадает с исходным значением NDP. Оригинал начинает с одинакового
текущего и последнего обработанного количества. Rust оставляет исходное число
в initial_amount_bits, а текущее и обработанное количество хранит отдельно.
Вместимость при этом берётся из NDP; исходный снимок не заменяет её. В лицензированном min_l_n1.dat
проверены, например, root row 0 со значением 1.0 и строка EXTO с local
row 1 в общей позиции 22 со значением 6000.0.
Для одной строки перенесён прямой путь Control.dll+0x10f30 с выключенным
флагом накопления анимационного изменения. API
apply_loaded_mission_control_node_delta принимает живой объект, глобальный
ключ строки и добавку. Например, у строки с максимумом 100 и количеством
40 добавка 20 даёт количество 60 и долю 0.6. Переполнение ограничивается
максимумом, отрицательный результат — нулём; при нуле ставится флаг истощения
0x10, а пополнение снимает его. Общий итог изменяется именно на разницу
нового и прежнего количества:
let amount = (previous + delta).clamp(0.0, maximum);
let total = (total + (amount - previous)).max(0.0);
let ratio = amount / maximum;
Это три отдельные операции над f32, с проверкой поддерживаемого численного
диапазона до записи состояния. Исходные initial_* поля сохраняются;
processed_amount_bits тоже не меняется, поскольку его обновляет другой
нативный проход — пересчёт стадий. Публикация selector 0x200 теперь применяется
к CPU-состоянию соответствующего узла модели. Результат
ControlNodeProviderPublication описывает уже выполненную запись. Пересчёт
стадий и вывод изменённой модели в окно этим API ещё не запускаются.
У AniMesh есть собственное поле значения узла, отдельное от доли Control.
Конструктор AniMesh.dll+0x123e0 начинает его с 1.0, а видимую стадию — с
0. Callback +0x5750 при selector 0x200 записывает число в поле +0x124
строки модели с шагом 0x130, не меняя её стадию +0x1c и соседние узлы.
Native oracle соединяет исходные Control+0x10f30 и AniMesh+0x5750; между
ними стоит только запись диагностической трассы. Прямая добавка 20 к
количеству 40 из максимума 100 записывает 0.6 и в provider, и в узел
модели. Эти поля нельзя объединять: наследование родительской доли в отдельном
стадийном проходе меняет provider, не повторяя callback 0x200.
В Rust mesh_state хранит это отдельное значение и видимую стадию;
неразрешённый узел прерывает изменение до записи остальных полей. Истощение корня либо узла
с критическим флагом 1 пока возвращает ошибку без изменения состояния:
эта ветвь требует полного lifecycle объекта, а не только нового количества.
Стадия пересчитывается отдельным вызовом
update_loaded_mission_control_node_stage. Для трёх состояний доли 1, 0.5
и 0.1 выбирают стадии 0, 1 и 2. Нулевая доля даёт служебную стадию
Control 3, но в модель записывается последняя видимая стадия 2. Поэтому
current_stage и mesh_state.visible_stage хранятся отдельно. Флаги 4 и 8
могут брать соответственно долю или стадию родителя; доля самой модели этим
пересчётом не переписывается.
После успешного вызова обработанное количество становится равным текущему,
даже если стадия осталась прежней. Истощённые защищённые узлы сохраняют
нативный ранний выход и не обновляют этот снимок. Неизвестные parent/count,
неподдержанные lifecycle-вызовы, FX и пересчёт геометрии на границе истощения
возвращают ошибку до записи. Аргумент update_effects сохраняет нативный смысл:
его нельзя молча отключать при включении полноценного игрового цикла.
На настоящем двухстадийном узле min_l_n1.dat тест уменьшает количество вдвое,
явно вызывает стадийный API без FX и получает Control stage 1, видимую стадию
модели 1 и обновлённое обработанное количество. Это проверка отдельной
операции на ресурсах, а не переключение кадра в игровом окне.
На исходном min_l_n1.dat прямое уменьшение количества строки ресурсного
устройства вдвое меняет ответы 0xe00 и Behavior 0x1002 с 1.0 на 0.5.
Обычный последующий шаг runtime сохраняет это состояние. Это воспроизводимый
тест связки загрузки и запросов, но пока не автоматическое изменение через
clock Control.
После загрузки количество в строках может меняться. Контроллер распределяет
добавку либо пропорционально свободному месту, либо по очереди: при максимумах
[30, 60, 50], текущих значениях [10, 20, 0] и добавке 22 первый способ
даёт [14, 28, 10]. При вычитании последовательный способ начинает с последней
строки. Пропорциональное вычитание делит запрос по текущим количествам.
У каждой изменившейся строки есть отдельный накопитель изменений количества: он
получает фактически применённую разницу, а не исходный запрос.
Большие суммы округляются в двоичном формате. Поэтому после нескольких
маленьких изменений общий запас иногда оказывается чуть больше первоначальной
ёмкости; оригинал продолжает считать с этой отрицательной разностью, а не
исправляет её отдельным правилом.
У строк есть и связи с родительскими узлами. Нативный Control.dll сначала
обновляет запрошенную строку через 0x11220, затем 0x11130 ищет её детей
среди строк с большим индексом. Каждый найденный ребёнок обновляется вместе со
своим поддеревом, и только потом обход продолжается со следующим соседом.
Например, для родителей [-1, 0, 0, 1, 3] порядок строк становится
1 → 3 → 4 → 2: сначала весь первый участок дерева, затем его сосед.
Стадия увеличивается, когда доля ресурса уменьшается. Для числа вариантов
count внутренняя стадия вычисляется как count - ceil(f32(count * ratio)),
а видимое значение ограничивается диапазоном модели. Например, при трёх стадиях доли
[0.1, 0.4, 0.5, 1, 0] дали [2, 1, 1, 0, 3]; последнее значение — sentinel
истощения, а в модель передаётся индекс не выше count - 1. Нативные проверки
подтвердили арифметику и порядок обхода.
После обновления родителя Control сравнивает его старую стадию с новой и
проверяет флаг истощения 0x10. Если стадия выросла либо строка истощена,
количество каждого ребёнка полностью уменьшается тем же путём, что и обычная
добавка ресурса. Для ребёнка без защитной маски родительские флаги 0xc0
задают флаг 0x80; в обычной ветви переход ставит 0x100 при ухудшении и
очищает у ребёнка 0x80. Маска 0x140 запрещает только изменение этих флагов:
уменьшение количества и рекурсивный вызов всё равно выполняются. Дочерние
стадии пересчитываются и тогда, когда родитель не ухудшился.
Выбранная стадия также задаёт, какие данные MSH попадают в пересчёт видимости.
У одного синтетического узла первый слот давал [42, 330], а слот другой стадии —
[102, 570]. В проверенных векторах одинаковые данные при скрытии и повторном
включении восстановили суммы; переходы с разными слотами — нет. Эти проверки
исполняют исходные DLL на тестовых строках, но не являются запуском Control на
полной миссии.
В Rust update_loaded_mission_control_node_stage сохраняет отдельный шаг одной
строки, а propagate_loaded_mission_control_node_stage обновляет строку и её
поддерево в том же порядке, что native 0x11130. Весь снимок provider-таблицы
публикуется только после успешной обработки всех потомков. Если позже по обходу
понадобится неизвестная связь узлов, lifecycle, FX или пересчёт видимости,
операция сообщает об этом и оставляет снимок без изменений. Эта CPU-проекция
ещё не подключена к игровому Control clock или выводу модели в окно.
Прямой путь изменения количества остаётся отдельной операцией.
Подробные границы приведены в техническом приложении.
Для точного повтора важен порядок округления. При исходных значениях [2^24, 2]
и запросе -1 оригинал сначала складывает общий итог с изменением, затем
вычитает прежний итог. Из-за округления фактическое изменение получается -2.
Rust переносит этот порядок, сохраняет native накопитель каждой строки и
запускает обязательный проход стадий с effects. Если ему нужны ещё не
реализованные FX или lifecycle-вызовы, таблица остаётся без изменений. Эти
операции обновляют только CPU-снимок и пока не подключены к игровым часам Control.
Отдельный путь объекта класса 4 умножает интервал на контактный коэффициент и
передаёт изменение строке. В проверенном примере коэффициент 1, интервал 2,
и значение строки уменьшается на 2; этот коэффициент не является ставкой
производства из профиля. Источник абсолютных часов и их доставка из World3D
в Control прослежены по инструкциям. Активная бюджетная часть проверена на
искусственном хосте класса 10; полного тика объекта на миссии ещё нет.
У ресурсного шага отдельные часы. После принятого межкадрового интервала
Control заново разыгрывает срок обновления, даже если предыдущий вызов его
ещё не достиг. Например, с периодом 500 и начальным RNG 0x12345678
исходная DLL пропустила времена 500 и 501, затем выполнила ресурсный шаг
на 502. Выбрать случайный срок один раз и ждать его было бы неверно.
Принудительный шаг тоже расходует RNG; неактивный объект синхронизирует часы
без такого расхода. Это локальные native проверки, а не работающий Rust
Control: запросы Rust 0xe00 и 0x1002 читают текущее состояние строк,
которое меняют явные API одной строки и распределения, а 0x1005 — загруженную
ставку профиля.
Точные нативные векторы и открытые границы описаны в приложении.
На x87 control word 0x007f каждое умножение округляет мантиссу до 24 бит,
но промежуточная степень может выйти за диапазон f32 и вернуться в него при
следующем умножении. Native oracle проверяет такой порядок для getter-а
0x300. Rust запрос сохраняет тот же порядок, но поддерживает только конечные
нормализованные значения или ноль на входе, промежуточном и конечном этапах.
Остальные случаи за этим численным диапазоном он отклоняет; полный x87
accumulator здесь не эмулируется.
Лицензированный тест на Unit DAT UNITS/BUILDS/MINE/min_l_n1.dat проходит через
fr_l_mine.ctl и fr_l_mine.ndp: у найденного tag-26 descriptor ключ равен
2 в своей записи, raw коэффициент — 0x3f800000. Корневой NDP содержит 22
строки, sidecar примонтированного EXTO — 4; в общую provider-таблицу входит
25 строк после пропуска локальной строки 0. Тест также сравнивает порядок
всех source-row индексов с порядком строк самих sidecar таблиц. Он вызывает
Rust query по восстановленному native правилу адресации, а не getter оригинальной
DLL на полной миссии. Значение 1.0 подтверждает начальное состояние по native
конструктору, но не наблюдение живого Control tick-а.
Control system и physical model
LoadControlSystem загружает настройки controller-а: ограничения скорости,
ускорения, поворота и режимы управления. LoadPhysicalModel загружает форму и
параметры, используемые для столкновений. Visible MSH не обязан совпадать с
collision representation: для физики часто нужна более простая и устойчивая
форма.
Практичная runtime-модель:
struct PhysicalState {
Transform transform;
Vec3 linear_velocity;
Vec3 angular_velocity;
float requested_speed;
float requested_turn;
uint32_t flags;
};
struct CollisionProxy {
ObjectId owner;
ShapeSet shapes;
Bounds broad_phase_bounds;
uint32_t category_mask;
};
Названия полей здесь описывают контракт совместимой реализации, а не точный layout исходного C++-объекта.
Collision pipeline
Один расчётный шаг удобно разделить так:
- controller получает желаемые
speed/turnот Behavior или manual input; - вычисляет кандидатный transform на основе
dt; - обновляет broad-phase bounds collision object;
- collision manager находит потенциальные пары и terrain candidates;
- narrow phase вычисляет контакт или допустимый остаток перемещения;
- physical state корректируется;
- World3D получает итоговый transform;
- событие
GMSG_COLLISION_DETECTEDотправляется в согласованной фазе.
Позиция collision event после narrow phase является рекомендуемой фазой реализации и согласуется с назначением сообщения, но точный call-site относительно всех correction steps требует динамической трассировки Control. Удаление объекта из обработчика остаётся отложенным по правилам World3D. Collision manager не должен хранить прямую незащищённую ссылку на объект, который уже pending-delete.
CTLD и physical resources
Реестр прототипов ссылается на *.ctl, *.cpt и связанные control resources.
В заголовке CTLD идут пять u32; обозначим первые три counts как S, M и
T. Native layout задаёт начало control-row table формулой
128 + S * (156 + 16 * M) + 4 * S * S; за ним следуют T records по 36 байт.
В record известны node ID (i32, +0), два endpoint frame (f32, +4, +8),
начальный blend (f32, +0x0c) и raw flags (u32, +0x20). Остальные поля
нельзя выводить из этой позовой привязки.
Пример fr_l_plant.ctl имеет counts [14, 0, 5, 11, 13]: формула даёт
row_start = 3096 (0xC18), а пять control rows заканчиваются на +0xCCC.
GOG Control.dll function RVA 0x9950 читает blend из +0x0c и flags из
+0x20. Его branch RVA 0x99C4..0x9A66 применяет flags: при flags & 0x1
blend один раз переносится через границу — из значения выше 1 вычитается
1, к значению ниже 0 прибавляется 1; без этого флага blend ограничивается
[0, 1]. Затем flags & 0x2 инвертирует значение (1 - blend). Константы
Control.dll+0x3B188 = 1.0 и +0x3B18C = 0.0 подтверждают границы этих
сравнений.
Control в call site RVA 0x99B5 передаёт в AniMesh через slot +0x30 пару A
[-1, -1] и пару B из endpoint полей строки. В call site 0x9A7B slot +0x28
сохраняет нормализованный blend по +0x114 и weight 1 по +0x118. AniMesh
update RVA 0x8BF2..0x8C76 вычисляет
frame = (1 - blend) * endpoint_a + blend * endpoint_b; update RVA 0x12560
выбирает B-state из-за weight 1. В static preview строки с flags & 0x4 != 0
пропускаются до проверки float полей, поскольку endpoint поля в них могут быть
sentinel -1. Повторные node ID сохраняют исходный порядок, поэтому последняя
включённая строка задаёт кадр узла. Привязки сохраняются отдельно для каждого
экземпляра компонента, даже когда PreparedVisual разделяется через cache.
Это даёт начальную позу preview, но не запускает игровой Control, AI или полный
runtime animation path. Остальные
секции CTLD shape records и contact solver не входят в эту интерпретацию.
Читатель ресурсов проходит общую структуру typed records и сохраняет строки
эффектов type 4 как данные: индексы таблицы и строки, raw condition words,
selector, пару ресурсов и три offsets в point list компонента. Неизвестный tag
или неподдерживаемый layout задаёт PreparedControlEffectSection::Unsupported,
сохраняя уже разобранные pose bindings; повреждённая распознанная структура
отклоняет подготовку ресурса. В GOG corpus fr_l_plant.ctl содержит 38 строк
type 4, в том числе четыре f_smalllight_y с offsets 104–107 и smoke_fr_02 с
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 подтверждён общий читатель
+0x21D50: таблица виртуальных методов tag 2 (+0x3CBD8) вызывает его через
обёртку +0x29650, остальные перечисленные vtable указывают прямо на него в
позиции +0x1C. Читатель потребляет
заголовок размером 0xB0 байт со счётчиком u32 по +0xAC, затем count
значений u32, слово длины и, при ненулевой длине, строку с завершающим NUL;
указатель возврата указывает на следующую запись. Rust-тест с tags 3, 4,
10, 27 проверяет переход между записями после непустых values и строк,
а также отказ при оборванном завершающем NUL. Это подтверждает структурный
формат, но не назначает смысл всем коэффициентам устройств.
Resolver прототипов хранит явную registry-ссылку на .cpt отдельно от ссылки
на .ctl. Если строки type 4 разобраны и явная ссылка на CPT есть, подготовка
читает ровно указанные archive/name; имя CPT не выводится из имени CTL. В CPT
сначала идёт u32 count, затем count записей по 36 байт и параллельные им
имена по 32 байта. Подготовка проверяет count, точную длину и конечность шести
float-полей. Она сохраняет flags, знаковые слова по смещениям +4 и +8, два
набора из трёх float и raw name. Control+0xB210 копирует слово +4 в
производную запись точки, а Control+0x1B4F0 передаёт это значение callback-у
выбора model node; роль слова +8 и координатный frame float-полей пока не
назначены.
Native lookup выбирает первую группу CPT с тем же C0, а type-4 path прибавляет
к её началу row offset. Для корневых компонентов fr_l_plant и fr_l_gener
C0 равен 0, поэтому найденные offsets 104–107, 119–121 и 75–82, 85 указывают на
соответствующие записи в их CPT-списках. Общий путь выбора группы для других C0
и семантика sentinel u32::MAX пока не реализованы; reader сохраняет offsets
как raw значения. Отсутствующая явная CPT оставляет point_list отсутствующим,
но не мешает исследовать строки эффектов. Это только чтение ресурсов: проверка
условий, загрузка FX materials, activation, выполнение FX opcodes и видимый
рендер эффектов пока не реализованы.
После типизированных записей CTLD хранит 21 ссылку на группу эффектов. В
fr_l_plant.ctl первая ссылка равна 8, остальные 20 содержат u32::MAX; за
ними расположены 13 таблиц групп. При разборе по адресу +0x8B10 к каждой
ссылке, кроме u32::MAX, прибавляется число групп, зарегистрированных до
таблиц этого CTL; получившиеся индексы сохраняются в control mapping. При
событии 0x80000020 код по адресу +0x7830 сразу выходит, если поле дескриптора
+0xC0 равно нулю. Разбор по адресу +0x8B10 вызывает диспетчер групп +0x2800
с индексом группы по умолчанию только при ненулевом +0xC0. Назначение поля
дескриптора и условия его заполнения на стороне producer пока неизвестны.
initial_effect_group хранит проверенную ссылку на группу по умолчанию. Это
поле описывает данные CTL и не выполняет activation, загрузку FX materials,
FX opcodes или рендер эффектов.
CPU-only обход MISSIONS/Autodemo.00/data.tma подготовил четыре CTL с 80
строками type 4 и четыре явных CPT-списка (всего 322 записи). Среди пар были
fortif.rlb/fr_l_plant.ctl и fortif.rlb/fr_l_plant.cpt (155 точек), а также
parts.rlb/u_all_def_a_01.ctl и parts.rlb/u_all_def_a_01.cpt (6 точек). Среди
240 raw offset words не встретился u32::MAX; наибольшее значение — 121. Это
наблюдение ограничено указанной выборкой и не определяет sentinel для других
ресурсов.
Terrain queries и movement handoff
Control получает world-interface Terrain и использует поверхность, faces и ускорители для высоты, нормали и пересечений. Навигационный маршрут сообщает, куда двигаться, но итоговый transform определяется по физической поверхности. При переходе через склон controller должен согласовать горизонтальный шаг, высоту и ориентацию с terrain normal.
Порядок операций должен быть детерминированным: пары collision objects
сортируются по стабильному ID, contacts обрабатываются в фиксированной
последовательности, а интеграция использует одну политику dt и округления.
Иначе одинаковая миссия постепенно расходится даже без сети.
Reference controller в текущем runtime
fparkan-runtime::advance_reference_movement — намеренно маленький
детерминированный мост между сохранённым mission transform и TerrainWorld.
Он получает OriginalObjectId, явную XY-цель и положительный максимум шага,
находит только live/registered object по исходному ID, двигает XY не более чем
на этот шаг и записывает высоту из TerrainWorld::height_at. Orientation и
scale остаются исходными IEEE-754 words; функция не изменяет clock, очередь
World3D или animation state. Возвращаемое значение означает достижение именно
заданной XY-цели.
Это не восстановленный Control, Behavior или navigation controller:
функция не строит маршрут, не использует dt, скорость, terrain normal,
коллизии, acceleration и оригинальные AI decisions. Она существует как
проверяемый reference path, который фиксирует границу будущих controller-ов и
не позволяет renderer-у или gameplay обходить terrain query. Non-finite input,
неположительный шаг, отсутствующая миссия/объект и XY вне поверхности дают
явную ошибку без частичного изменения transform.
Licensed test на GOG Autodemo.00 запускает этот путь для live mission object,
находит существующую поверхность и проверяет точные XY/Z words после snap. Это
доказывает связывание current runtime data, но не доказывает семантику
оригинального движения.
Control.dll пересобрана при неизменных размере, imports и пяти именах/ordinals
exports; RVA всех пяти exports изменились. Форматы и cross-module boundary
сохранились, но точное physical/collision behavior нельзя считать побайтно тем
же.
Покадровая привязка выше описывает начальную позу компонента. Она не задаёт семантику CTLD shape records и не восстанавливает contact solver.
Differential test обеих частей должен воспроизводить движение без препятствий, slope following, pair collision, timing collision event и удаление объекта в callback. Сравниваются transforms и contact events по tick, а не только факт успешной загрузки.
Ввод, камера и управление
World3D нормализует клавиатуру, мышь и joystick в общие scan codes и manual
commands. Win32 message handler вызывает UpdateManualEventsList; перед
обработкой новой порции сообщений основной цикл вызывает
ClearManualEventsList. Снимок клавиатуры очищается отдельно через
stdClearKeyboard.
Публичная поверхность включает WinMsg2ScanCode, converters для
keyboard/mouse/joystick/predicate, ScanCode2Str, ManualCommand2Str,
stdIsKeyPressed, lock/unlock keyboard и чтение mouse shift. Это позволяет
хранить конфигурацию управления независимо от физического устройства.
Event, state и axis
Ввод имеет минимум три семантики:
- edge event -- нажатие или отпускание в текущей порции сообщений;
- held state -- клавиша остаётся нажатой между кадрами;
- analog value -- смещение мыши или положение joystick axis.
Manual command дополняет источник коэффициентом, режимом wrap, dead
zone/threshold и временной характеристикой. Строки camera bindings показывают
команды MCMD_STATE, MCMD_ANGLE_X, MCMD_ANGLE_Y, режимы MAN_WRAP и
MAN_NOTWRAP, а также параметры ускорения в миллисекундах.
Simulation читает подготовленный input snapshot. Renderer не должен самостоятельно опрашивать OS, иначе одно и то же нажатие будет зависеть от частоты кадров.
Joystick через DirectInput
Joystick.dll экспортирует:
QueryJoy
CreateJoy
ReleaseJoy
SetJoyRange
PeekJoyMessage
GetJoyCaps
QueryJoy обнаруживает устройство, CreateJoy получает интерфейс DirectInput,
SetJoyRange нормализует оси в диапазон движка, PeekJoyMessage выдаёт
очередное унифицированное событие.
При потере устройства чтение может вернуть ошибку acquired state. Интерфейс
следует повторно получить, очистить устаревшее состояние и продолжить.
Hot-unplug не должен оставлять последнюю ось навсегда отклонённой.
GetInstalledJoyNames и SetActiveJoy в World3D связывают device list с
game-facing выбором.
Два camera interface
World3D предоставляет stdSetCurrentCamera/stdGetCurrentCamera: это камера
как часть игрового состояния. Terrain имеет
stdSetCurrentCamera2/stdGetCurrentCamera2: concrete camera, которую world
renderer использует для matrices, viewport и visibility.
LoadCamera экспортирован обоими модулями. По call graph World3D-вариант
играет роль component bridge, а Terrain-вариант связан с concrete
camera/world implementation. Это архитектурный вывод: точные class names и
layout не восстановлены.
Минимальные данные камеры:
world position and orientation
view matrix
projection parameters / field of view
near and far planes
viewport rectangle
camera mode and target object
manual angles/state
Такая граница позволяет game code работать с абстрактной камерой, не зная внутреннего renderer representation.
Camera commands и порядок кадра
Подтверждены команды CMD_CAMERA_LEFT, CMD_CAMERA_RIGHT, CMD_CAMERA_UP,
CMD_CAMERA_DOWN, CMD_CAMERA_CENTER, CMD_CAMERA_INFRARED, а также
spotlight и внешние/миссионные camera modes. Горизонтальный угол использует
wrap, вертикальный -- ограниченный диапазон. Center плавно возвращает обе оси к
заданному значению.
Порядок кадра:
- собрать manual events;
- обновить camera controller во время calculation;
- вычислить итоговый transform и ограничения;
- перед render установить current camera;
- передать её Terrain и sound listener;
- после кадра сохранить mode-specific state.
Camera smoothing должно использовать игровое время или специально подтверждённые часы. Привязка к render delta делает управление разным при 30 и 144 FPS.
Звуковая подсистема
Ngi32 создаёт низкоуровневый DirectSound backend. services.dll публикует
ISoundServer. Game, Terrain и FX работают уже через эти интерфейсы:
воспроизводят 2D/3D sources, меняют volume и связывают listener с camera.
Погода, осадки и звуковые события
Погода для игрока — это одновременно движущиеся точки на экране и звуковой
фон. Расписание sky.ske говорит, в какой момент действует дождь, снег или
молния, а sky.wea назначает имена материалов. Система окружения каждый
кадр превращает это состояние в два списка: мировые частицы для renderer-а и
звуковые переходы для audio backend-а.
sky.ske + sky.wea
-> состояние погоды
-> EnvironmentFrame
-> Particle / Lightning (renderer)
-> StartLoop, SetLoopVolume, StopLoop, OneShot (sound)
Такое разделение нужно для понятной границы ответственности. Система погоды решает, что произошло и где находится источник. Renderer решает, как нарисовать прозрачный квадрат или молнию. Audio backend разрешает архив и имя, создаёт источник звука и сравнивает его с текущим listener.
У осадков есть объём перед наблюдателем. Эталонные границы имеют глубину
2..50, половины углов 0.65 и 0.4875 радиана. Для текущей камеры:
half_y = tan(vertical_fov / 2) * 50
half_x = half_y * aspect_ratio
Размер осадков получает тот же camera query, что и native Terrain: ширина
viewport делится на горизонтальный FOV в радианах.
horizontal_fov = 2 * atan(tan(vertical_fov / 2) * aspect_ratio)
precipitation_scalar = viewport_width / horizontal_fov
rain_size = precipitation_scalar * 0.0065
snow_size = precipitation_scalar * 0.0195
Число точек получает масштабирование по отношению текущего объёма к эталонному и округляется к ближайшему чётному целому:
N = round_even(clamp(current_volume / reference_volume, 0, 1)
* density * intensity * 1000)
В рабочем эмиттере density равна единице. Точка сначала появляется в
локальных координатах этого объёма, затем получает мировую позицию. Дождь
движется с мировым вектором [0.5, 0, -60], снег — [0.5, 0, -4].
Поворот камеры меняет видимую область и проекцию, но не вращает эти векторы.
Когда точка пересекает грань, она переводится в локальные координаты,
циклически переносится на противоположную грань и возвращается в мир.
Поэтому источник звука и положение частицы должны храниться в мировых координатах. У дождя хвостом экранной полосы становится предыдущая мировая позиция; у снега остаётся квадрат в текущей позиции. Scalar вычисляется из projection и реального viewport каждого кадра, поэтому отдельная настройка размера не нужна.
Дождевой loop следует жизненному циклу состояния:
| Событие | Действие audio backend |
|---|---|
StartLoop |
открыть объявленный sample и начать пространственный loop |
SetLoopVolume |
сохранить loop и применить новую интенсивность как громкость |
StopLoop |
остановить текущий loop |
OneShot |
создать отдельный источник и воспроизвести sample молнии один раз |
Интенсивность между ключами sky.ske интерполируется, поэтому SetLoopVolume
может приходить на каждом кадре. Имя из расписания сохраняется. Если указано
только atm_rain1.wav, архив остаётся пустым и audio owner использует
библиотеку миссии; запись archive/name задаёт архив явно. Ресурс загружается
лениво и кэшируется после проверки, чтобы не читать все возможные погодные
звуки при запуске миссии.
Молния использует отдельный таймер. Для интенсивности I и случайного U из
15-битного диапазона задержка имеет вид
delay_ms = round_even((1 - min(I, 0.95)) * 60000 * U)
После срока выбираются мировые X и Y из LightningBounds, а Z копируется из
границ. Затем объект ждёт 6000 миллисекунд перед новой попыткой. Визуальный
контракт передаёт renderer-у material и numeric body opcode 3; native
descriptor [40, 40, 600] начинается на sampled_z + 300. При нулевом
локальном смещении его концы находятся на sampled_z и sampled_z + 600.
Opcode 1 в это же время отдельно обновляет point light; он не задаёт размеры
или UV quad. Звуковой OneShot использует ту же позицию, а его opcode 2
параметры min_distance=100, max_distance=1500, frequency_ratio=1
проходят в spatial source. Renderer и audio backend применяют numeric FX
поля и lifetime из заголовка эффекта, а CPU не подменяет их собственной
шириной или fade-кривой.
Подробные поля EnvironmentFrame, правила wrap и границы CPU-модуля собраны
в справочнике эффектов окружения.
Ambient variations и переход день/ночь
ambient_music_loop запускает THEME сразу после открытия миссии. Вариации
не выбираются последовательным счётчиком: audio owner получает dt_seconds
как приращение времени кадра и после строгого условия elapsed > delay
выбирает один sample.
Первый положительный tick поэтому запускает первую вариацию, а задержка между
следующими попытками равна 10 + rand() % 10 секунд.
В ambient_music_variation поддерживаются три независимых пула:
| Пул | Ключи | Когда выбирается |
|---|---|---|
| default | DEFAULT_VARIATION1..n |
когда отсутствуют оба пула DAY и NIGHT |
| day | DAY_VARIATION1..n |
длина базового RGB активного небесного объекта больше 1.1 |
| night | NIGHT_VARIATION1..n |
длина базового RGB активного небесного объекта не больше 1.1 |
Базовый RGB передаётся до camera-dependent glare и берётся у первого активного
небесного объекта. Если существует хотя бы один day/night пул, выбранный
пустой пул остаётся пустым: он не заменяется default или противоположным пулом.
LIBRARY у ambient_music_variation может отличаться от библиотеки theme;
если поле отсутствует, используется библиотека loop.
Индекс выбирается двумя 16-битными состояниями Iron3D:
a = (a << 1) xor b
b = (b >> 1) xor a
index = b % pool_length
При длине пула больше одного предыдущий индекс отбрасывается одной или более
повторными выборками. Последний индекс сохраняется при переходе между day и
night; для пустого пула sample не создаётся, но следующий таймер продолжает
работать. При пустом пуле native сбрасывает индекс в -1. Пауза окна сохраняет
логическое состояние таймера и индекса, поэтому
возобновление не перескакивает на случайную вариацию.
Публичные функции Ngi32:
niCreate3DSound
niGet3DSound
niGet3DSoundCaps
niMuteSound
Backend динамически вызывает DirectSoundEnumerateA и DirectSoundCreate;
параметр DisableDSound может полностью отключить этот путь.
Устройство и capabilities
Конфигурация учитывает 3D Sound, качество, reverse sound, частоту buffer,
режим постоянного воспроизведения и автоматический выбор лучшего устройства.
Эти значения преобразуются во внутренний capability/profile object до создания
sources.
Код содержит отдельный no-device state и строку 3D Sound was not initialized.
Отсутствие 3D sound обрабатывается отдельно от ошибок simulation/resources.
Новый runtime не должен позволять отсутствию звука разрушать simulation и
обязан возвращать звуковым командам явный no-device result.
Общий sound object разделяется между подсистемами и использует счётчик владельцев. Закрывать DirectSound следует после остановки всех sources и atmosphere/FX managers.
Sound resources и SWAV
Основная библиотека называется sounds.lib; mission.cfg также создаёт
именованные sound resources и variations. Legacy API rsLoadWave загружает
waveform из archive. Импорт MSACM32 подтверждает путь преобразования сжатых
wave-данных в формат playback buffer.
Resource identity состоит из library и name. Один sound asset может иметь несколько runtime sources с различными position, volume, pitch/flags и временем запуска. Поэтому кэшировать следует decoded sample/buffer, а source object создавать на событие.
FX opcode 2 хранит archive[32] + name[32] и обычно создаёт sound command.
Atmosphere использует отдельные loop/variation sources, например rain
background. Миссионный слой содержит voice events для завершения или провала
задания.
Проверенный SWAV-корпус:
Часть 1: 399 — 306 MS ADPCM, 93 PCM
Часть 2: 540 — 446 MS ADPCM, 93 PCM, 1 empty entry
Все непустые записи имеют RIFF/WAVE framing и частоту 22 050 Hz. В Части 2
entry ALIEN_ME.WAV имеет размер 0. Это присутствующий archive key без
decodable waveform.
Sound loader должен различать:
entry_missing;entry_empty;wave_invalid;decoded_sample.
Нулевой payload не передаётся RIFF parser-у и не должен приводить к чтению header за границей.
3D listener и sources
Перед world traversal stdRenderGame обновляет listener из camera transform.
Listener содержит position, orientation и, при наличии, velocity. Source
содержит world position и параметры затухания. Spatialization выполняется
backend-ом либо совместимой программной моделью.
camera transform
-> listener position/front/up
object or effect transform
-> source position
sample + source parameters
-> DirectSound 3D buffer
Прямо подтверждено обновление listener в начале stdRenderGame, до world
traversal. Sound events могут создаваться и в calculation/FX path, поэтому
нельзя утверждать, что listener предшествует созданию каждого source. Важно,
что spatial backend получает camera state текущего отображаемого кадра до
завершения его обработки. Перенос listener update после world render создаст
как минимум однокадровое рассогласование presentation.
Громкость, mute и CD-аудио
iron3d.dll применяет отдельные настройки эффектов и CD sound. Параметр
FORCE_CD_SOUND меняет политику выбора музыкального источника. niMuteSound
должен временно остановить вывод без разрушения sample cache и logical playback
state.
В новой реализации полезно разделить buses: master, effects, ambient, voice и music/CD. Это проектное решение совместимого backend-а, а не доказанный layout оригинального mixer-а. Оно позволяет применять старые коэффициенты, не переписывая individual source volume.
Граница service layer
Ngi32.dll с DirectSound/backend code не изменилась между Частями 1 и 2, но
services.dll пересобрана и уменьшилась на 4 096 байт. Поэтому low-level
decoder/device path подтверждается одной машинной реализацией, а service
lifecycle, GUI/audio wiring и defaults требуют раздельной трассировки обеих
частей.
Сетевая подсистема
Net инкапсулирует DirectPlay4A и lobby/service-provider API. World3D строит над транспортом player identity, mirror objects и игровые сообщения. Эти уровни следует разделять: DirectPlay отвечает за доставку bytes между players, World3D -- за смысл сообщения и владение объектом.
Application GUID:
{3C1D1F01-A870-11D1-8400-000021B14415}
Он передаётся network instance и service layer. Экземпляры с другим GUID не принадлежат одному логическому приложению.
Lifecycle соединения
Публичные функции Net покрывают полный цикл:
CreateNetworkInstance
-> select/use service provider
-> setup connection
-> enumerate or create session
-> join/create session
-> create local player
-> send/receive messages and player data
-> destroy player
-> close session
-> close connection
Поддерживаются providers эпохи DirectPlay: TCP/IP, IPX и modem/lobby варианты, если они установлены в системе. Функции явно проверяют, что DirectPlay enabled до enumeration, session и player operations. Неверный порядок вызовов должен возвращать понятную ошибку, а не разыменовывать пустой interface.
Sessions, players и адреса
Net предоставляет enumeration service providers и sessions, выбор host/join, player name/password/data, latency, максимальный размер сообщения, размер очереди, server player info и provider address. Lobby launch обрабатывается отдельной веткой.
Внутренняя модель должна хранить как минимум:
struct NetPlayer {
TransportPlayerId transport_id;
uint16_t game_player_number;
string name;
RawBytes player_data;
bool is_local;
bool is_host;
};
Transport ID нельзя использовать как постоянный ObjectId. NetWatcher связывает
временный DirectPlay identifier с номером игрока и World3D entities.
Игровые сообщения World3D
Подтверждённые имена message surface:
GMSG_CREATE_REMOTE_PLAYER
GMSG_APPEND_RESOURCE
GMSG_CHANGE_OBJECT_OWNER
GMSG_SET_PLAYER_DATA
GMSG_MISSION_DATA_PATH
GMSG_TAKE_OBJECT
GMSG_TEXT_FOR_PLAYER
GMSG_SYNC_STATE
GMSG_CREATE_MIRROR
GMSG_PAUSE_REMOTE_PLAYER
GMSG_CONFIRM_PLAYER_DATA
GMSG_KILL_PLAYER
SYSMSG_SET_TIME
SYSMSG_SET_PLAYER_NUMBER
GMSG_END_MESSAGE_SEQ
GMSG_REMOVE_RESOURCE
GMSG_COLLISION_DETECTED относится к общей очереди, но не обязательно
передаётся по сети. Message ID, payload size и delivery policy должны быть
частью явной schema. Нельзя сериализовать C++ pointers или native padding.
Mirror objects и ownership
Удалённо принадлежащий объект представлен local mirror instance. Он участвует в рендере и spatial queries, но authority над его созданием, ключевыми properties и удалением находится у owner player. Сообщение смены владельца обновляет эту границу; оно не должно создавать второй объект с тем же ID.
Типовой путь:
remote create message
-> validate player and ObjectId
-> resolve prototype/resources
-> CreateMirrorObject
-> apply initial state
-> AddMirrorObjectToGame
-> subsequent sync messages update mirror
При потере player NetWatcher инициирует предписанное удаление или transfer ownership через World3D queue. Мгновенное освобождение во время receive callback запрещено по тем же причинам, что и в calculation pass.
Сжатие и wire compatibility
netZipData и netUnZipData образуют встроенный слой упаковки payload. Он
находится выше транспорта: переход с DirectPlay на UDP/ENet не отменяет
необходимость воспроизводить формат упакованного сообщения, если требуется
соединение с оригинальной игрой.
Полный wire schema, framing и алгоритм сжатия пока не доказаны packet capture-ом. Поэтому нужны два режима:
- native compatibility -- отдельный adapter, реализуемый после трассировки оригинальных packets;
- modern multiplayer -- новая versioned protocol schema, использующая ту же game-message семантику, но не заявляющая совместимость с DirectPlay client.
Эти режимы нельзя незаметно смешивать. До доказательства native wire compatibility современный transport должен быть versioned и отделён от слоя, который претендует на совместимость с оригинальным клиентом.
Стабильность сетевого слоя
Net.dll и World3D.dll побайтно идентичны в обеих частях. Application GUID,
DirectPlay wrapper, mirror-object API и World3D message surface относятся к
одной машинной реализации.
Это подтверждает отсутствие отдельной сетевой реализации для Части 2, но не
закрывает wire schema: без packet/send-receive capture по-прежнему неизвестны
точное framing, reliability flags, payload layouts и алгоритм netZipData для
native interoperability.
Для binary regression достаточно одного профиля неизменённых DLL, но message captures должны включать контент обеих частей, потому что prototype/resource IDs и mission data различаются.
Контракты реализации
Совместимая реализация должна фиксировать не только результат, но и момент его появления в кадре. Для Behavior, Control, input, sound и network особенно важны tick boundaries: одна и та же команда, применённая на один tick раньше или позже, меняет дальнейшую симуляцию.
Trace-события
Минимальный trace для этого тома:
- input snapshot: edge events, held state, analog values;
- camera state: mode, target, angles, matrices, viewport;
- Behavior: target, areal, global path revision, local corridor;
- Wizard: requested vector, constraints, wizard path;
- Control: candidate transform, contacts, correction, final transform;
- World3D queue: message name, ObjectId, dispatch phase, deferred deletion;
- sound: sample key, source owner, position, event tick, listener state;
- network: player mapping, message ID, payload length, delivery policy.
Для рендера это связывается с render frame: camera и listener должны попадать в trace до world traversal, иначе нельзя отделить ошибку presentation от ошибки управления.
Проверки Behavior и сценариев
- script version mismatch даёт отдельную ошибку;
- event table читается lossless;
- VM body сохраняется без потери неизвестных bytes;
- отсутствующий
IArealMapне замалчивается; - non-walkable/non-reachable states дают diagnostic condition;
- одинаковый input log воспроизводит одинаковый sequence Behavior commands;
- resource names из TRF разрешаются через общий registry.
Проверки Control
- движение без препятствий;
- slope/terrain-following;
- симметричные pair-collision tests с переставленными IDs;
- contact event отправляется один раз в предписанной фазе;
- удаление объекта в collision callback безопасно;
- replay одинакового input log даёт одинаковые transforms;
- collision proxy перестраивается после смены component/model state.
Проверки input и камеры
- edge event не повторяется как held state;
- mouse/joystick axis сбрасывается по правилам snapshot;
- hot-unplug joystick не оставляет старое отклонение;
- camera horizontal angle wraps, vertical angle clamps;
- center command использует подтверждённое время, а не render FPS;
- Terrain и sound получают одну и ту же camera frame.
Проверки звука
- backend может отсутствовать без нарушения simulation;
- один decoded sample переиспользуется несколькими sources;
entry_missing,entry_emptyиwave_invalidразличаются;- listener совпадает с camera frame;
- loop source корректно переживает pause/resume;
- mute не сбрасывает position и time;
- missing sound resource содержит полную диагностическую цепочку;
- deterministic test сравнивает список sound events, а не waveform устройства.
Проверки сети
- нельзя создавать queue с активной сетью и нулевым player ID;
- session/player operations до enable/setup возвращают ошибку;
- сообщения проверяют длину до чтения payload;
- sequence/end markers обрабатываются в стабильном порядке;
- duplicate create mirror не создаёт второй instance;
- ownership change атомарно обновляет routing;
- pause/time messages применяются в одной simulation boundary;
- resource transfer имеет timeout
Network_ResourceTransferMaxDelay; - disconnect не оставляет objects с несуществующим owner;
- replay записанного message log даёт одинаковое World3D state.
resnet.log и NetWatch.log следует поддерживать как отдельные каналы: первый
относится к transport/resource exchange, второй -- к связи players и game
objects.
Границы знания
Подтверждены внешние interfaces, часть runtime order, значимые строки, конфигурационные параметры, corpus-level counts и стабильность ряда DLL между двумя частями. Открытыми остаются:
- instruction grammar
.scrи semantics всех VM opcodes; - точная семантика всех TRF-записей;
- полный layout CTLD shape records;
- contact solver и порядок всех correction steps;
- class layout камер, контроллеров, sound service и network watcher;
- DirectPlay wire framing, reliability flags и payload schema;
- алгоритм
netZipData/netUnZipData; - точные defaults service layer там, где DLL пересобраны.
Эти границы должны оставаться видимыми в документации и тестах. Если новая реализация вводит удобный современный abstraction layer, он обязан быть отделён от утверждений о native compatibility и покрыт отдельным trace.