feat: complete map viewer scene and static CTL pose preview

Complete the interactive mission viewer with environment rendering, audio events, dynamic shadows, free-flight camera controls, and per-component CTL pose sampling.
This commit is contained in:
2026-10-11 13:12:50 +04:00
parent aa51f3574d
commit a8faba8aad
42 changed files with 24411 additions and 1234 deletions
+236
View File
@@ -0,0 +1,236 @@
# Атмосфера и небо
Атмосфера Parkan задаётся двумя связанными ресурсами: `sky.ske` хранит
расписание, цвета и параметры, а `sky.wea` сопоставляет позиционные строки с
материалами. Ресурсы сначала разбираются в `TypedAtmosphere` и `SkyMaterials`,
затем на каждом кадре из расписания получается `AtmosphereFrame`. Модуль
`fparkan-fx::sky` превращает этот кадр в данные для рендерера: постоянную
геометрию купола, динамические цвета вершин, небесные слои, положение
светил, туман и нижнюю границу освещения.
## Игровые сутки и смена дня и ночи
Вызов `TypedAtmosphere::sample(real_seconds)` принимает секунды от начала
повторяющегося расписания. Дорожки идут в порядке файла и занимают свои
реальные длительности; после последней дорожки время возвращается к началу.
Внутри выбранной дорожки игровой день всегда проходит от 00:00 до 24:00:
```text
track_seconds = real_seconds_in_track
day_seconds = track_seconds / track_duration * 86400
```
Время ключа вычисляется по часам и минутам даты. Нативный конструктор
игнорирует сохранённое поле секунд, поэтому для планировщика используется:
```text
duration = (end_hour * 60 + end_minute) * 60
key_time = floor(duration * (key_hour * 60 + key_minute) * 60 / 86400)
```
Сэмплер ищет предыдущий и следующий ключ во всём расписании. Переход через
конец дорожки или конец цикла интерполируется так же, как переход внутри
дорожки. `SkeDate::seconds_of_day()` сохраняет все три компонента даты для
инструментов; оно не меняет формулу реального времени расписания.
День и ночь в самом формате не являются отдельным флагом. Их вид получается
из интерполированных цветовых параметров купола и состояния объектов солнца и
луны. Ключи с kind `0` и `1` запускают и останавливают небесное светило. Для
активного интервала модуль восстанавливает рождение объекта: ищет ближайший
предыдущий ключ `SunStart`, следующий соответствующий `SunStop`, вычисляет
реальное время начала и длительность, включая переход через цикл, и сохраняет
эти значения в `SunBirth`.
Имя в первой фиксированной строке определяет объект. Для строки `sun`
нативный код использует углы `[90, 30]` градусов и строку материала 3; для
`moon` — `[0, 50]` и строку 4. Начальный вектор орбиты строится по формуле
`[cos(theta), 0, -sin(theta)]`, где
```text
theta = (clamp((now - birth_start) / lifetime, 0, 1) * 1.2 - 0.1) * pi
```
Затем к нему применяется матрица рождения. У солнца и луны независимые
интервалы и независимые направления: при отсутствии активной луны нельзя
подменять её направление противоположностью солнца. `SunFrame::color` — это
единственный primary directional RGB. Второй источник получает направление
`-SunFrame::direction` и RGB из `SunSample::packed[1]`; оба источника для
активного объекта находятся в `SkyFrame::directional_lights` (сначала солнце,
затем луна). Экранная геометрия использует `-SunFrame::direction`, а
`world_center` размещает её по нативной формуле относительно camera
translation и половины far plane. Материал, строка и состояние объекта
берутся из его `SunBirth` и таблицы `sky.wea`.
## Купол
Оригинальный `CSky` строит конечный купол, а не кубическую skybox. Значения
конструктора, подтверждённые в Terrain, такие: высота `10000`,
`theta_max = pi / 4`, вертикальный масштаб `1`, `16` азимутальных секторов и
`5` колец. Радиус вычисляется как
```text
radius = height * 0.5 / sin(theta_max * 0.5)^2
```
Вершина с индексом 0 находится в зените `[0, 0, height * z_scale]`.
Остальные вершины идут в порядке `azimuth`, затем `ring`; для них
```text
phi = azimuth / azimuth_count * 2*pi
theta = (ring + 1) / rings * theta_max
x = radius * sin(theta) * sin(phi)
y = radius * sin(theta) * cos(phi)
z = (radius * cos(theta) + height - radius) * z_scale
```
На сектор приходится один треугольник веера и по два треугольника на каждую
пару соседних колец. Текстурные координаты трёх исходных стадий используют
масштабы `[1, 15, 3]` для `position.x / radius` и `position.y / radius`.
Цвет зенита и первого пояса берётся из palette 12. Следующие пояса используют
группы palette 8..11, 4..7 и 0..3 с интерполяцией по азимуту.
Позиции, signed normal bytes, UV и индексы создаются один раз через
`SkyGeometryConfig::mesh`/`SkySystem::new`. Последующие кадры меняют только
цвета вершин методом `SkyMesh::update_colors`; индексы и topology не
пересоздаются. Ошибка конфигурации возвращается как `SkyGeometryError`, а
ошибка входного кадра — как `SkyUpdateError`; они не превращаются в пустой
буфер. Рендерер может загрузить `SkySystem::mesh()` в статический
vertex/index buffer и обновлять только небольшой цветовой диапазон.
Нормали купола повторяют native `Terrain47BFF` contract: каждый компонент
хранится как signed `i8`, округляется режимом nearest-even и умножается на
`127.0`. `SkyVertex::normal_vector()` декодирует эти bytes делением на `127`;
зенит имеет `[0, 0, 127]`. Вершинный backend должен передавать signed byte
representation без трактовки его как unsigned `0..255`.
## Облака, звёзды и спрайты
`sky.wea` — позиционная таблица. В стандартном AutoDemo строки имеют роли:
| Строка | Роль | Имя в AutoDemo |
|---:|---|---|
| 0 | фон/туманность | `ENV_NEBULA_0` |
| 1 | звёзды | `ENV_STARS` |
| 2 | облака | `ENV_CLOUDS` |
| 3 | солнце | `ENV_SUN_3` |
| 4 | луна | `ENV_MOON` |
| 5 | первый flare | `ENV_FLARE_00` |
| 6 | второй flare | `ENV_FLARE_01` |
| 7 | снежинка | `SNOWFLAKE` |
| 8 | капля дождя | `RAIN_DROP` |
Это наблюдаемые строки, а не глобальные имена, на которые можно полагаться в
миссии. `SkyMaterials::parse` сохраняет числовой id и имя каждой строки;
миссия может заменить любой материал. `SkyLayerKind` и `SkyLayerFrame` дают
роль, позиционную строку, активность, intensity, UV stage и фазу времени.
Нативный порядок купола находится в `SkyFrame::passes`: экранный gradient,
nebula row 0 со stars row 1 как второй texture (material mode 4), dome
gradient и clouds row 2. Для nebula/stars/clouds используются UV scales
`1/15/3`; clouds получают translation Z `-5000` относительно камеры и
directional RGB из `sky.packed[0]`. Это четыре фиксированных pass, а не три
независимых цветных слоя.
Цвет первого screen-gradient и последнего пояса вычисляет
`SkyFrame::screen_gradient(yaw_radians, glare_rgb_delta)`. Native `CSky` сначала
округляет `(yaw + pi) * 180 / pi` режимом nearest-even, выбирает сектор
`(floor(degrees / 90) - 2) mod 4`, а затем смешивает соседние цвета
`SkySample::colors[0..4]`. Остаток сектора умножается на точный коэффициент
из PE `65F64` (`0x3C360B61`, примерно `0.01113567`), alpha результата равна
`255`. `glare_rgb_delta` — входной RGB delta от light manager: для каждого
канала это `max(current_primary_light_rgb - base_sun_rgb, 0)`. Каждый компонент
затем преобразуется так: `d <= 1 -> d*0.8`, `1 < d <= 3 -> d*0.1 + 0.7`,
`d > 3 -> 1`.
`SkyGradientFrame::horizon_floor` задаёт RGB minimum для dome palette, а
`clamp_dome_color` применяет его, сохраняя alpha исходного цвета.
Солнце, луна и две строки flare являются направленными экранными слоями.
Нативный quad использует UV
`[[.005,.005],[.005,.995],[.995,.995],[.995,.005]]`. Полуразмеры sun/moon в
пикселях вычисляет `sprite_half_size_pixels`: `viewport_width / horizontal_fov
* .325 * .5 * values[0/1]`, где `values` — первые два tail float SKE.
Цвет sprite и его alpha берутся из `SunSample::packed[0]`, отдельно от
directional `SunFrame::color`.
`SkyFrame::sun_optics` принимает projected sun, viewport, camera forward и
результат world-ray visibility. Он применяет native 15-degree glare cone и
возвращает фиксированные 12 `FlareQuad`; flare slots активны только при
видимом солнце, `length(primary RGB) > 1.1` и cone amount `<= .1`. При cone
`> .1` остаётся только glare boost. Renderer отвечает за сам projection и
occlusion query.
Дождь, снег и молния используют те же состояния расписания, но их camera-local
геометрия и звуки обновляются `EnvironmentSystem`. Для активного интервала
ресурсы берутся только из ключа, который его запустил. Интенсивность дождя,
снега и молнии — четвёртый trailing float; цвет активной погоды использует
RGB palette 12 с минимумом `80/255` на канал и alpha `150/255`.
## Туман и свет
Нативное обновление `CSky` записывает две дальности и один packed RGB.
`FogFrame` повторяет этот контракт:
```text
fog.start = sky.values[0] * 700
fog.end = sky.values[1] * 700
fog.color = RGB(sky.packed[1]) / 255
```
Поля не скрывают порядок исходных параметров: значения сохраняются в
`SkySample`, а преобразование выполняется только в `fog_frame`. Vulkan path
передаёт цвет и дальности в frame uniforms; vertex shader вычисляет линейный
коэффициент по расстоянию до камеры, а fragment shader смешивает RGB материала
с fog color в ветвях combiner, которые используют туман. Конкретный draw state
может обходить это смешивание.
Цвет primary света солнца — RGB palette 12, нормированный делением на 255 и
умноженный на trailing parameter 2. `SunFrame::direction` — нормированный
вектор после матрицы рождения и направление primary light; sprite/world
geometry использует его противоположность. Второй directional RGB берётся из
packed-параметра 1 без дополнительного умножения intensity. Когда объект
неактивен, sampled поля сохраняются, но оба light slot и sprite inactive.
В native `CShade` sampled RGB из состояния атмосферы также устанавливает
глобальную нижнюю границу освещения. Для каждого RGB-канала она объединяется
операцией `max` с накопленным цветом материала. Поэтому `SkyFrame` отдаёт
`lighting_floor` из того же sampled packed RGB, а владелец renderer передаёт
его в глобальный light state. Фиксированный ambient-цвет в этом месте меняет
ночной уровень и не соответствует цепочке Terrain → CShade → Ngi32.
## Бинарный формат `sky.ske`
Файл little-endian, версия 5. В начале находятся `i32 marker = -1`,
`u32 version = 5` и число дорожек. Дорожка содержит `u32 version = 1`, число
ключей, две даты по 32 байта и ключи. Дата состоит из восьми `u32`; для
планировщика значимы слова 3, 4 и 5 (часы, минуты, секунды), а остальные слова
сохраняются без интерпретации.
Ключ имеет `u32 version = 3`, дату, raw kind, opaque word, четыре packed ARGB
цвета, два `f32`, пятнадцать packed ARGB цветов, шесть length-prefixed строк,
четыре trailing `f32` и список length-prefixed ссылок. Строка хранится как
`u32 byte_length` и ровно столько байт без завершающего NUL. Неизвестный kind
сохраняется как `AtmosphereKind::Unknown`, как и opaque слова/строки.
В конце находятся дата и два opaque `u32`. Декодер ограничивает количества и
размеры строк, отклоняет NaN/Infinity во float-полях и требует точного конца
файла. Трейлер выбирает исходную дорожку и позицию час/минута; метод
`initial_offset_seconds()` складывает длительности предыдущих дорожек и
применяет ту же формулу floor.
Из интерполированного ключа `SkySample` формируется в исходном порядке:
```text
colors = [header[1], header[2], header[0], header[3],
palette[0], palette[3], palette[1], palette[2],
palette[4], palette[7], palette[5], palette[6], palette[8]]
values = header_values
packed = [palette[11], palette[13]]
```
Солнце и луна используют palette 12 и trailing parameter 2 для RGB,
parameters 0 и 1 сохраняются как raw values, а packed-параметры светила —
palette 10 и 14. Kind `0/1` управляет солнцем и луной по имени, `3/4` —
дождём, `5/6` — снегом, `8/9` — молнией; kind `2` и `7` меняют
интерполируемое состояние. Поля, для которых в native renderer ещё нет
проверенного назначения, остаются raw в Rust API и не получают придуманных
значений по умолчанию.
+203
View File
@@ -0,0 +1,203 @@
# Эффекты окружения
Погода в Parkan состоит из двух связанных частей. Файл `sky.ske` задаёт
расписание: когда начинается дождь или снег, как меняются интенсивность и цвет,
какие имена ресурсов принадлежат активному интервалу. Таблица `sky.wea` задаёт
материалы, которыми эти осадки рисуются. `fparkan-fx::environment` соединяет
оба входа с текущей камерой и возвращает один `EnvironmentFrame`.
```text
sky.ske
-> AtmosphereFrame: время, цвет, интенсивность, ссылки на ресурсы
sky.wea
-> SkyMaterials: имена материалов строк 7 (снег) и 8 (дождь)
камера + EnvironmentSystem
-> EnvironmentFrame: мировые частицы, экранные квадраты, гром и дождевой loop
```
Модуль не владеет графическим устройством или звуковой картой. Renderer
разрешает имя материала и отправляет геометрию в прозрачный проход, а audio
backend разрешает архив и имя звука. Это позволяет одной и той же модели
погоды работать в preview и в игровом цикле.
## Входы и границы
Для расписания используется такой вызов:
```rust
let frame = environment.update_atmosphere_with_materials(
dt_seconds,
&atmosphere_frame,
&sky_materials,
camera,
);
```
`AtmosphereFrame` передаёт ссылки из стартового ключа активного интервала.
Для дождя первая ссылка используется как звук фонового loop. Строка вида
`archive/name` разделяется на архив и имя. У простой строки `name` архив
остаётся пустым: звуковой владелец подставляет библиотеку, выбранную миссией.
Имя не заменяется глобальным именем из AutoDemo.
`SkyMaterials` хранит разобранную таблицу, поэтому строки 7 и 8 являются
положением в формате исходной игры, а не зашитыми названиями. В AutoDemo там
находятся `SNOWFLAKE` и `RAIN_DROP`; миссия может передать другие имена. Если
активной погоде не назначен материал, частицы для неё не создаются.
На входной границе конечные значения приводятся к безопасному диапазону:
`dt_seconds` и интенсивность неотрицательны, интенсивность и каждый компонент
цвета ограничены единицей, а нечисловые значения заменяются нулём. Камера
получает конечную ортонормированную основу, FOV, aspect ratio и размер
viewport от владельца кадра.
## Объём и движение осадков
Исходный эмиттер хранит прямоугольный объём в координатах камеры. Его
эталонные параметры таковы:
```text
near = 2
far = 50
half_angle_x = 0.65
half_angle_y = 0.4875
```
Для эталонной камеры:
```text
half_x = tan(half_angle_x) * far
half_y = tan(half_angle_y) * far
origin = [near, -half_x, -half_y]
extent = [far - near, 2 * half_x, 2 * half_y]
```
Текущий FOV и aspect ratio пересчитывают две боковые грани по той же глубине:
```text
half_y = tan(vertical_fov / 2) * far
half_x = half_y * aspect_ratio
```
Количество частиц зависит от объёма и интенсивности. Вспомогательная
формула использует округление FISTP к ближайшему чётному целому:
```text
N = round_even(
clamp(current_volume / reference_volume, 0, 1)
* density
* intensity
* 1000
)
```
Текущий эмиттер передаёт `density = 1`. Публичный
`PrecipitationVolume::particle_count_for` оставляет этот множитель явным для
владельца, который хранит собственную плотность.
Порядок обновления частицы важен:
1. при создании три 15-битных значения детерминированного генератора
заполняют локальный объём;
2. локальная точка переводится через основу камеры в мировую позицию;
3. мировая позиция интегрируется с мировым вектором скорости;
4. для проверки границ позиция временно переводится обратно в локальные
координаты, и к каждой оси применяется floor-based wrap;
5. после wrap точка снова переводится в мир, а её хвост сбрасывается на голову.
Скорости не принадлежат системе координат камеры:
```text
rain = [ 0.5, 0, -60 ]
snow = [ 0.5, 0, -4 ]
```
Это мировые векторы. Поворот камеры меняет область появления, wrap и
проекцию, но не вращает уже движущуюся каплю вокруг наблюдателя. В
`EnvironmentPrimitive::Particle` `position`, `velocity`, `world_head` и
`world_tail` имеют мировые координаты.
Размер частиц состоит из коэффициента класса и масштаба погодного объекта:
```text
rain_size = scalar * 0.0065
snow_size = scalar * 0.0195
```
`scalar` вычисляется из той же проекции, которую передаёт владелец кадра:
```text
horizontal_fov = 2 * atan(tan(vertical_fov / 2) * aspect_ratio)
scalar = viewport_width / horizontal_fov
```
Это соответствует запросу размера native camera (`Terrain` primary vtable
slot `+6c`): ширина `RECT` делится на горизонтальный FOV в радианах. Поэтому
`Camera::with_projection(...).with_viewport([width, height])` должен получать
реальный viewport каждого кадра; отдельного setter-а масштаба нет.
`ScreenBillboard` содержит мировые голову и хвост, их проекцию и четыре NDC
угла с UV. `half_size` остаётся native-величиной в пикселях, а обе глубины
хранятся отдельно; renderer не смешивает эти единицы. Дождь использует
предыдущую мировую голову как хвост и строит полосу перпендикулярно экранному
направлению движения. Снег остаётся квадратом в текущей голове, использует
исходную таблицу знаков и минимальный depth fade `0.1`. Renderer сам решает,
как загрузить этот квадрат и как смешать его материал.
## Молния и звуковые события
`decode_env_lightning_fxid` извлекает из FXID длительность, ссылки opcode 3 и
2, первые четыре числа opcode 1 и полный body opcode 1. Имена не подменяются
глобальным whitelist-ом: материал и звук принадлежат выбранному FXID.
`LightningEffect` устанавливается через `set_lightning_effect`; без него
погодный таймер не создаёт визуал или звук.
Активный объект использует следующий таймер. При интенсивности `I` сначала
применяется `min(I, 0.95)`, затем для случайного `U` из 15-битного диапазона
вычисляется задержка:
```text
delay_ms = round_even((1 - min(I, 0.95)) * 60000 * U)
```
После достижения срока выбираются X и Y из переданных `LightningBounds`, а Z
берётся без изменения. Если границы не заданы, используется позиция камеры.
Следующая попытка разрешена через 6000 миллисекунд. Длительность уже
созданного FX берётся из его заголовка. Renderer получает native descriptor
`[40, 40, 600]`, начало эффекта на `sampled_z + 300`; при нулевом локальном
смещении концы quad находятся на `sampled_z` и `sampled_z + 600`. Opcode 3
задаёт материал, локальное смещение, scale и lifetime visual quad. Opcode 1
вычисляется отдельно как динамический point light и не задаёт размеры или UV
quad.
CPU не подменяет numeric поля шириной или линейным lifetime fade.
Звуки возвращаются рядом с графикой как `SoundEvent`:
```text
StartLoop -> начать дождевой loop
SetLoopVolume -> обновить его громкость при интерполяции погоды
StopLoop -> остановить loop
OneShot -> воспроизвести разовый звук молнии
```
Каждое событие содержит архив, имя, мировую позицию и линейную громкость.
Для FX-звука также передаются native `min_distance`, `max_distance` и
`frequency_ratio`; у дождевого loop используются `0`, бесконечность и `1`.
Событие `OneShot` молнии использует ту же случайно выбранную позицию, что и
визуальный эффект, а его диапазон и частота читаются из opcode 2: в
поставленном `env_lightning` это `100`, `1500` и `1`. Audio backend загружает
объявленный архив и запись лениво, кэширует проверенный sample, создаёт
отдельный spatial source, применяет DirectSound range/pan и текущий listener.
Отсутствие устройства вывода не должно останавливать симуляцию.
## Что остаётся за владельцем
`EnvironmentSystem` не извлекает камеру из renderer, не разрешает материалы
или звуки и не выполняет финальный GPU pass. Владелец должен передать основу
камеры, projection, реальный viewport, `SkyMaterials` и декодированный FXID.
Из projection и viewport модуль сам получает native scalar осадков.
CPU-контракт проверяет движение, wrap, количество, время молнии и порядок
звуковых переходов. Он не заявляет совместимость с нераскрытой семантикой
всех FX opcode, с точным native RNG sequence или с конкретным mixer/device
backend. Подробное описание связи с listener и mission audio находится в
[томе VI](../tomes/06-behavior.md#погода-осадки-и-звуковые-события).
+73 -7
View File
@@ -38,13 +38,9 @@ MAT0 имеет type ID `0x3054414D`, обычно расположен в `Mate
```c
#pragma pack(push, 1)
struct Mat0PrefixV4Plus {
struct Mat0Header {
uint16_t phase_count;
uint16_t animation_block_count;
uint8_t metadata_a;
uint8_t metadata_b;
uint32_t metadata_c_raw;
uint32_t metadata_d_raw;
};
struct Phase34 {
@@ -54,8 +50,78 @@ struct Phase34 {
#pragma pack(pop)
```
Versioned fields читаются только если версия их содержит. Для старых версий
используются runtime defaults, а raw values сохраняются.
`attr1` читается из NRes entry metadata и сохраняется вместе с разрешённым
материалом. Это runtime flags материала; его нельзя путать с `attr2`, который
задаёт версию payload. Versioned prefix bytes читаются только если версия их
содержит, а raw values сохраняются для последующих слоёв.
### Pipeline category
Call site извлекает native category как `(attr1 >> 2) & 0xF` и передаёт её в
`LegacyPipelineState::with_material_category`. Таблица содержит пять
подтверждённых категорий; helper возвращает `None` для остальных значений и не
меняет уже выбранные depth и cull state.
| category | source/destination colour factors | alpha test |
| ---: | --- | --- |
| 0 | `ONE / ZERO`, blending disabled | disabled |
| 1 | `SRC_ALPHA / INV_SRC_ALPHA` | enabled, `GREATER_EQUAL` |
| 2 | `SRC_ALPHA / ONE` | enabled, `GREATER_EQUAL` |
| 3 | `ZERO / SRC_COLOR` | enabled, `GREATER_EQUAL` |
| 4 | `DEST_COLOR / SRC_COLOR` | enabled, `GREATER_EQUAL` |
The mapping comes from the native five render-state pairs selected by the
material category. Alpha reference remains draw-range data, while the Vulkan
pipeline key includes the blend mode, depth mode, cull mode and alpha-test
variant so ranges with different native state do not share a pipeline.
Каждая phase занимает 34 байта: 18 parameter bytes и 16-byte C string имени
текстуры. Loader переводит параметры в коэффициенты так:
| bytes | runtime value |
| --- | --- |
| `p0..p2` | additive RGB, `p / 255` |
| `p3` | opacity, `p * 0.01` |
| `p4..p7` | directional values, `p / 255` |
| `p8..p11` | specular values, `p / 255` |
| `p12..p15` | extra values, `p / 255` |
| `p16` | integer power |
| `p17` | signed TEXM page index |
Texture name и page index выбираются из текущей phase. Они не смешиваются с
соседней phase.
## Animation
После phase table идут `animation_block_count` плотных блоков. Каждый блок имеет
`u32 header_raw`, `u16 key_count`, затем `key_count` записей по три `u16`:
```text
u32 header_raw
u16 key_count
repeat key_count:
u16 phase_index
u16 end_time_ms
u16 raw_k2
```
`header_raw & 7` задаёт режим: `0` loop, `1` ping-pong, `2` clamp, `3`
random-per-query. Значения `4..7` сохраняются как unknown. `header_raw >> 3`
является interpolation mask: bits `1`, `2`, `4`, `8` смешивают группы
`p0..p2`, `p4..p6`, `p8..p10`, `p12..p14`, а bit `16` смешивает `p3`.
Компоненты `p7`, `p11`, `p15`, `p16` и `p17` копируются из текущей phase.
Время для блока вычисляется как `clock_ms - wear_row_start_ms` с wrapping
`u32` subtraction. Для loop берётся остаток от последнего `end_time_ms`, для
ping-pong нечётный цикл идёт в обратном направлении, clamp после duration
выбирает последнюю phase, а random-per-query использует переданное случайное
значение по модулю duration. Ключ выбирается по интервалу
`previous_end <= local_time < end_time`; следующий ключ циклически замыкается на
первый. На ping-pong turnaround и clamp после duration native routine проходит
общий float interpolation path, включая unsigned subtraction endpoint arithmetic;
это может дать extrapolated coefficients при ненулевой mask. Переданный в
sampler `animation_block_index` уже выбран вызывающим runtime из block table;
packed WEAR handle и номер строки WEAR являются отдельными значениями.
## Fallback
+79 -21
View File
@@ -19,7 +19,7 @@ type 10 strings and node names
type 13 Batch20 records
type 15 auxiliary stream
type 17 auxiliary data
type 18 rare stream
type 18 packed UV1 (optional secondary coordinates)
type 19 animation frame map
type 20 rare auxiliary table
```
@@ -40,23 +40,77 @@ struct Node38 {
};
```
`slot_index[lod * 5 + group]` выбирает geometry slot. `0xFFFF` означает
отсутствие геометрии для комбинации LOD/group.
В исходном layout это 3 состояния по 5 LOD: `slot_index[state * 5 + lod]`.
Публичные Rust-обёртки пока сохраняют исторические имена `Lod` и `Group`,
поэтому в `selected_slot` аргумент `Lod` задаёт native state (0..2), а
`Group` задаёт native LOD (0..4). `0xFFFF` означает отсутствие геометрии для
комбинации state/LOD.
Validated `ModelAsset` также сохраняет decoded type 8 keys и type 19 map как
Validated `ModelAsset` сохраняет decoded type 8 keys и type 19 map как
`ModelAnimation`. `node38_fallback_pose` возвращает pose по `fallback_key`,
то есть доказанный static input. `parent_or_link == 0xFFFF` означает root;
иначе это parent index, обязательно меньший индекса child. Этот контракт
подтверждён на тестах анимации с оригинальными ресурсами обеих частей и защищён fallback-ом:
модель с нарушенным порядком не получает придуманную hierarchy.
а `node38_fallback_hierarchy` собирает parent-before-child hierarchy.
`node38_sampled_hierarchy` использует type 19 map в пределах объявленного
числа кадров и интерполирует соседние type 8 keys; при отсутствии usable map
или выходе за frame count используется fallback key. Для покомпонентного
sampling `node38_sampled_hierarchy_at_times` принимает отдельный optional float
time для каждого узла: `Some(time)` выбирает type 19 frame по округлению
`time - 0.5` к ближайшему целому с ties-to-even, затем интерполирует выбранный
type 8 key и его следующий key в исходный float time; `None` использует
fallback key. Значения должны быть конечными и неотрицательными. Это
portable-поведение при default-nearest rounding; полного совпадения со всеми
режимами округления x87 оно не заявляет. `parent_or_link == 0xFFFF`
означает root, иначе это parent index, который должен быть меньше индекса
child. Модель с нарушенным порядком не получает придуманную hierarchy.
В legacy-camera static preview стандартный узел уже получает свой fallback pose
до внешнего TMA/Iron3D transform. Parent pose поворачивает child translation,
затем translation суммируется, а rotations умножаются; после полученной global
pose применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия
намеренно дублируется на draw-range узла, потому что один source vertex может
быть нарисован разными node poses. Это static fallback hierarchy, а не полная
animation parity: dynamic type-19 frame-map sampling остаётся отдельной задачей.
Type 8 записывает quaternion в порядке WXYZ, после decode `AnimKey24` хранит
его как XYZW. Native AniMesh `+0x12560` строит local matrix через NGI32
`g_FastProc + 0x38`; scalar implementation выдаёт transpose обычной active
`Pose` basis (`M01=2(xy+zw)`, `M02=2(xz-yw)`, `M10=2(xy-zw)`). Поэтому путь
Node38 MSH сопрягает source-local quaternion ровно один раз после sampling и
до hierarchy composition (меняет знаки компонент `x`, `y`, `z`). Для fallback
poses применяется та же граница. Общий animation decoder и другие форматы
анимации не изменяются.
В legacy-camera static preview без `--static-animation-frame <u16>` используются
сохранённые начальные значения из явной ссылки каждого prototype на `.ctl`.
Узлы без control binding получают time `0`. Для каждой component instance
обрабатываются её CTLD bindings, поэтому общий deduplicated MSH не смешивает
controls разных компонентов. Строки идут в source order, и последняя включённая
строка для одного узла заменяет предыдущую. Flag `0x01` выполняет один wrap
значения blend на единицу, иначе значение ограничивается `[0, 1]`; flag `0x02`
инвертирует результат (`1 - blend`), а строки с `0x04` пропускаются. Выбранный
float time равен `(1 - blend) * frame_a + blend * frame_b`. Если он выходит за
type 19 map, MSH sampler использует fallback key; если после одной wrap/clamp и
invert получается отрицательное или нечисловое время, preview завершится
понятной ошибкой. Явный `--static-animation-frame` остаётся global override
для всех компонентов. Parent pose поворачивает child translation, затем
translation суммируется, а rotations умножаются; после полученной global pose
применяется `Rz * Ry * Rx`, scale и mission translation. Геометрия намеренно
дублируется на draw-range узла, потому что один source vertex может быть
нарисован разными node poses. Это только начальная статическая pose: она не
запускает игровой Control/AI и не обещает полного совпадения с runtime или
всеми x87 rounding modes.
## Vertex streams
Основные vertex streams имеют фиксированный source index. У type 3 `attr1`
задаёт количество вершин, а `attr3` — размер записи в байтах. Type 4
содержит четыре signed bytes нормали. Type 5 и optional type 18 содержат по
два little-endian `uint16` на вершину:
```c
struct PackedUv16x2 {
uint16_t u;
uint16_t v;
};
```
Type 5 — primary UV0, type 18 — secondary UV1 для lightmap/detail stage.
Для type 18 loader требует `attr3 == 4`, совпадение `attr1` и длины payload,
а также одинаковое число записей с type 3. Обе пары декодируются как
`packed / 1024.0`; renderer обращается к UV1 по тому же `source_index`, что и
к UV0. Отсутствующий type 18 означает, что secondary stage не имеет authored
координат.
## Slot and batch
@@ -82,16 +136,20 @@ Type 13 задаёт draw ranges:
#pragma pack(push, 1)
struct Batch20 {
uint16_t batch_flags;
uint16_t material_index;
uint16_t opaque4;
uint16_t opaque6;
uint16_t material_index_hi; // +0x02, native high selector/slot field
uint8_t material_index; // +0x04, WEAR material selector
uint8_t lightmap_index; // +0x05, 0xFF means no lightmap
uint16_t local_batch_index; // +0x06
uint16_t index_count;
uint32_t index_start;
uint16_t opaque14;
uint16_t vertex_count; // +0x0E
uint32_t base_vertex;
};
#pragma pack(pop)
```
Index check выполняется как `base_vertex + index < vertex_count` для всего
используемого slice.
Byte `material_index` выбирает строку WEAR; high word at `+0x02` сохраняется
отдельно для native overrides. Loader проверяет, что
`index_start + index_count` остаётся внутри type 6, а каждый использованный
индекс через `base_vertex` попадает в position stream. `vertex_count` и
`local_batch_index` доступны renderer для исходного draw contract.
+8
View File
@@ -69,6 +69,14 @@ projection matrix contract.
## Parity risks
`VulkanStaticCamera::from_legacy_d3d7` keeps the D3D7 view and projection
reconstruction in `fparkan-render`, then negates the vertical projection term
once at the Vulkan adapter boundary. D3D7's top-down viewport places positive
NDC Y at the top; the Vulkan swapchain uses a positive viewport height, so the
adapter flips NDC Y while the shader preserves `gl_Position`. The generic
`from_row_major_view_projection` path remains unchanged; its callers supply
Vulkan-ready matrices, as the free-flight camera already does.
- x87 precision and rounding;
- scalar/SIMD `g_FastProc` differences;
- object, batch and transparent primitive order;