Документирование и обновление спецификаций
Some checks failed
Test / Lint (push) Failing after 1m10s
Test / Test (push) Has been skipped
Test / Render parity (push) Has been skipped

- Обновлены спецификации `runtime-pipeline`, `sound`, `terrain-map-loading`, `texture`, `ui` и `wear`.
- Добавлены разделы о статусе покрытия и оставшихся задачах для достижения 100% завершенности.
- Внесены уточнения по архитектурным ролям, минимальным контрактам и требованиям к toolchain для каждой подсистемы.
- Уточнены форматы данных и правила взаимодействия между компонентами системы.
This commit is contained in:
2026-02-19 11:07:04 +04:00
parent a281ffa32e
commit 0d7ae6a017
24 changed files with 1043 additions and 1313 deletions

View File

@@ -1,5 +1,35 @@
# AI system
Документ описывает подсистему искусственного интеллекта: принятие решений, pathfinding и стратегическое поведение противников.
Страница фиксирует границы подсистемы AI на уровне движка:
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ai.dll`.
- выбор целей;
- тактические приоритеты;
- координация с `Behavior`, `ArealMap`, `Missions`.
## 1. Текущая зафиксированная часть
1. AI работает поверх ареалов/клеток карты, а не напрямую поверх render-геометрии.
2. Результат AI передается в behavior/command-слой как набор целевых состояний и команд.
3. Решения AI зависят от миссионных триггеров и состояния объектов мира.
## 2. Контракт интеграции
В 1:1 реализации AI должен быть совместим с:
1. системой ареалов (`Land.map`);
2. объектными категориями (`BuildDat.lst`);
3. поведением юнитов (`behavior.md`);
4. миссионными условиями (`missions.md`).
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- роль AI в общей архитектуре и точки интеграции с соседними подсистемами.
Осталось:
1. Полный формат runtime-AI состояний и таблиц решений.
2. Полные правила выбора цели/маршрута/приоритета огня.
3. Полная спецификация влияния миссионных скриптов на AI.
4. Набор тест-кейсов «AI tick parity» для побайтного/пошагового сравнения с оригиналом.

View File

@@ -1,5 +1,31 @@
# ArealMap
Документ описывает формат и структуру карты мира: зоны/сектора, координаты, размещение объектов и связь с terrain и миссиями.
`ArealMap` — подсистема топологии мира и логических зон.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `ArealMap.dll`.
Подробный бинарный формат `Land.map` и связь с terrain описаны в:
- [Terrain + ArealMap](terrain-map-loading.md)
## 1. Роль в движке
1. Хранит ареалы, связи между ареалами и клеточный индекс.
2. Используется для навигации, логики объектов и AI-решений.
3. Связывает геометрию карты с миссионной и поведенческой логикой.
## 2. Минимальный runtime-контракт
1. Валидный граф ареалов и edge-link связей.
2. Валидная cell-grid индексация (`cellsX/cellsY` + hit lists).
3. Согласованные идентификаторы ареалов для AI/Behavior/Missions.
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- бинарный контракт `Land.map` и pair-загрузка с `Land.msh`.
Осталось:
1. Полная доменная семантика `class_id`/`logic_flag` по всем игровым сценариям.
2. Формальная спецификация API-запросов к ArealMap (поиск зон, фильтры, события).
3. Набор parity-тестов поведения навигационных запросов на одинаковых входах.

View File

@@ -1,5 +1,28 @@
# Behavior system
Документ описывает поведенческую логику юнитов: state machine/behavior-паттерны, взаимодействия и базовые правила боевого поведения.
`Behavior` — слой исполнения состояний юнитов между AI-решением и низкоуровневым control-командованием.
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Behavior.dll`.
## 1. Роль в кадре
1. Принимает решения из AI.
2. Переводит их в state machine юнита.
3. Формирует команды движения/атаки/действий в world/control-слой.
## 2. Внешние зависимости
1. `ArealMap` (доступность/топология).
2. `Missions` (триггеры и ограничения сценария).
3. `Control` (выполнение команд).
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- архитектурная роль подсистемы и ее место в runtime-пайплайне.
Осталось:
1. Полная спецификация finite-state машин по типам юнитов.
2. Полная таблица переходов, таймаутов и приоритетов.
3. Формализация входных/выходных структур поведения для 1:1 эмуляции.
4. Поведенческие parity-тесты на фиксированных replay-сценариях.

View File

@@ -1,5 +1,28 @@
# Control system
Документ описывает подсистему управления: mapping ввода (клавиатура, мышь, геймпад), обработку событий и буферизацию команд.
`Control` — подсистема входа и маршрутизации команд (пользовательских и системных).
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Control.dll`.
## 1. Роль
1. Преобразует ввод устройств в команды движка.
2. Синхронизирует управление камерой, UI и объектами мира.
3. Передает команды в gameplay-подсистемы с учетом активного режима игры.
## 2. Минимальный контракт совместимости
1. Детерминированный mapping input -> command.
2. Стабильная обработка очереди команд в пределах кадра.
3. Корректный приоритет UI-фокуса над world-input.
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- место control-слоя в архитектуре и базовый runtime-контур.
Осталось:
1. Полная карта input actions и режимов обработки.
2. Формат внутренних очередей команд и их сериализация.
3. Спецификация edge-case поведения (повтор клавиш, захват мыши, hotkey-конфликты).
4. Пошаговые parity-тесты на записанных последовательностях ввода.

View File

@@ -0,0 +1,51 @@
# Documentation coverage audit
Дата аудита: `2026-02-19`
Корпус данных: `testdata/Parkan - Iron Strategy`
## 1. Проверка форматов архивов
Результаты:
- `NRes`: `120` архивов, roundtrip `120/120` (byte-identical)
- `RsLi`: `2` архива, roundtrip `2/2` (byte-identical)
- подтвержден один совместимый quirk: `sprites.lib`, entry `23`, `deflate EOF+1`
Инструмент:
- `tools/archive_roundtrip_validator.py`
## 2. Проверка рендерных форматов
Результаты:
- `MSH`: `435/435` валидны
- `Texm`: `518/518` валидны
- `FXID`: `923/923` валидны
- `Terrain/Map` (`Land.msh` + `Land.map`): `33/33` без ошибок/предупреждений
Инструменты:
- `tools/msh_doc_validator.py`
- `tools/fxid_abs100_audit.py`
- `tools/terrain_map_doc_validator.py`
## 3. Глобальный статус по подсистемам
| Подсистема | Статус | Что блокирует 100% |
|---|---|---|
| Архивы (`NRes`, `RsLi`) | практически закрыта | формализация редких не-ASCII/служебных edge-case |
| 3D geometry (`MSH core`) | высокая готовность | семантика opaque-полей и канонический writer «с нуля» |
| Animation (`Res8/Res19`) | высокая готовность | полный FP-parity на всех edge-case |
| Material/Wear/Texture | высокая готовность | полная field-level семантика служебных флагов и writer-профиль |
| FXID | высокая готовность | полная field-level семантика payload по каждому opcode |
| Terrain/Areal map formats | высокая готовность | доменная семантика `class_id/logic_flag`, ветка `poly_count>0` |
| Render pipeline | хорошая | полный pixel-parity набор эталонных кадров в CI |
| AI/Behavior/Control/Missions/UI/Sound/Network | начальное покрытие | требуется полная спецификация форматов и runtime-контрактов |
## 4. План доведения до 100%
1. Закрыть field-level семантику opaque/служебных полей в 3D/FX/terrain подсистемах.
2. Завершить canonical writer paths для авторинга новых ассетов без copy-through.
3. Зафиксировать и автоматизировать pixel/frame parity-критерии в CI.
4. Расширить подсистемные спецификации (`AI`, `Behavior`, `Missions`, `Control`, `UI`, `Sound`, `Network`) до уровня «полный формат + полный runtime-контракт + parity-тесты».

View File

@@ -3,7 +3,7 @@
`FXID` — бинарный формат эффекта в движке Parkan: Iron Strategy.
Эта страница задаёт контракт формата и исполнения на уровне, достаточном для 1:1 порта рендера/симуляции эффектов и для lossless-инструментов.
Связанный контейнер: [NRes / RsLi](nres.md).
Связанные контейнеры: [NRes](nres.md), [RsLi](rsli.md).
## 1. Контейнер
@@ -185,4 +185,18 @@ struct ResourceRef64 {
## 11. Статус валидации
- Формальные инварианты FXID зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
- В текущем рабочем окружении нет полного набора игровых архивов (`testdata` без payload), поэтому массовая повторная проверка корпуса здесь не выполнялась.
- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `923/923` FXID payload без ошибок.
## 12. Статус покрытия и что осталось до 100%
Закрыто:
1. Контейнер FXID, fixed-size командный поток, opcode-покрытие `1..10`.
2. Базовый runtime-контур исполнения эффекта.
3. Корпусная валидация формата на retail-данных.
Осталось:
1. Полная field-level семантика payload каждого opcode для авторинга новых эффектов «с нуля».
2. Формальная спецификация всех `time_mode` веток на уровне точных числовых формул и edge-case поведения.
3. Полный набор пиксельных parity-тестов FX (оригинал vs новый рендер) на фиксированных сценах.

View File

@@ -127,4 +127,18 @@ struct KeyRaw {
## 10. Статус валидации
- Инварианты MAT0 зафиксированы в текущем toolchain проекта (`docs/specs` + `tools`).
- В этом окружении нет полного игрового корпуса, поэтому статистика по всем материалам не пересчитывалась.
- Структурная валидация MAT0 включена в корпусный прогон `tools/msh_doc_validator.py` на полном retail-наборе.
## 11. Статус покрытия и что осталось до 100%
Закрыто:
1. Бинарный layout `MAT0` и правила чтения фаз/анимационных блоков.
2. Fallback-цепочка материала.
3. Контракт сохранения opaque-полей для lossless editor path.
Осталось:
1. Полная семантика всех битов `attr1` и `metaA/B/C/D` для авторинга новых материалов.
2. Полный writer-профиль «канонический MAT0» для генерации ассетов без copy-through.
3. Набор визуальных parity-тестов по material phase animation на реальных моделях.

View File

@@ -6,3 +6,13 @@
- [Wear table (`WEAR`)](wear.md)
- [Texture (`Texm`)](texture.md)
- [Render pipeline](render.md)
## Статус покрытия и что осталось до 100%
Закрыто:
1. Страница корректно декомпозирована на отдельные объектные спецификации.
Осталось:
1. Поддерживать единый changelog согласованности между `material.md`, `wear.md`, `texture.md` и `render.md`.

View File

@@ -1,5 +1,33 @@
# Missions
Документ описывает формат миссий и сценариев: начальное состояние, триггеры и связь миссий с картой мира.
Подсистема `Missions` управляет сценарием:
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `MisLoad.dll`.
- стартовыми условиями;
- триггерами;
- победой/поражением;
- синхронизацией с AI/Behavior/World.
## 1. Что уже зафиксировано
1. Миссии связаны с картами (`Land.msh`/`Land.map`) и объектными категориями.
2. Скриптовые ресурсы хранятся в архивных контейнерах (`NRes`) и участвуют в runtime-логике.
3. Миссионные события влияют на AI и поведение объектов через общий gameplay-слой.
## 2. Минимальный runtime-контракт
1. Детерминированный порядок обработки триггеров в кадре.
2. Единая шкала времени миссии для всех подсистем.
3. Согласованность идентификаторов объектов между mission-data и world-state.
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- связь миссионной подсистемы с форматом ресурсов и runtime-контуром.
Осталось:
1. Полная спецификация форматов миссионных скриптов/таблиц.
2. Полный перечень типов триггеров и их параметров.
3. Формальные правила разрешения конфликтов триггеров в одном кадре.
4. Набор replay parity-тестов «миссия от старта до завершения».

View File

@@ -109,4 +109,18 @@ uint16_t map_words[]; // size/2 элементов
## 6. Статус валидации
- Форматные проверки включены в `tools/msh_doc_validator.py`.
- В текущем окружении полный игровой корпус MSH не подключен в `testdata`, поэтому массовый прогон здесь не выполнялся.
- Корпусная валидация анимационных инвариантов включена в прогон `tools/msh_doc_validator.py` на полном retail-наборе.
## 7. Статус покрытия и что осталось до 100%
Закрыто:
1. Контракт `Res8 + Res19` и fallback-логика выбора ключа.
2. Базовая интерполяция поз и blending двух сэмплов.
3. Канонические инварианты writer path для существующих ассетов.
Осталось:
1. Полная фиксация численного поведения на всех FP-edge-case (включая платформенные различия округления).
2. Полный writer-профиль для авторинга новых анимаций без опоры на reference copy-through.
3. Набор runtime parity-тестов «frame-by-frame pose equivalence» на длинных анимациях.

View File

@@ -9,7 +9,8 @@
- [Material](material.md)
- [Texture (Texm)](texture.md)
- [Render pipeline](render.md)
- [NRes / RsLi](nres.md)
- [NRes](nres.md)
- [RsLi](rsli.md)
## 1. Общая модель
@@ -174,5 +175,19 @@ for each node:
## 8. Статус валидации
- Инварианты формата реализованы в `tools/msh_doc_validator.py`.
- В текущем окружении нет загруженного полного корпуса игровых MSH в `testdata`, поэтому массовый прогон по ассетам здесь не выполнялся.
- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `435/435` MSH-моделей без структурных ошибок.
## 9. Статус покрытия и что осталось до 100%
Закрыто:
1. Базовые таблицы geometry path (`Res1/2/3/4/5/6/7/13`).
2. Критичные range-инварианты slot/batch/index.
3. Правила совместимого writer/editor для lossless работы с существующими ассетами.
Осталось:
1. Полная семантика части opaque-полей (`Slot68` tail, `Batch20` opaque-поля) для authoring без copy-through.
2. Полная формализация редких веток (`Res1.attr3 != 38`) на расширенном корпусе.
3. End-to-end writer для генерации новых игровых MSH с подтвержденным runtime-паритетом.

View File

@@ -1,277 +1,118 @@
# 3D implementation notes
Контрольные заметки, сводки алгоритмов и остаточные семантические вопросы по 3D-подсистемам.
Контрольная страница с практическими правилами реализации 3D-пайплайна и с перечнем незакрытых зон.
Документ intentionally high-level: без ссылок на внутренние функции/адреса.
---
Связанные страницы:
## 5.1. Порядок байт
- [MSH core](msh-core.md)
- [MSH animation](msh-animation.md)
- [Material (`MAT0`)](material.md)
- [Texture (`Texm`)](texture.md)
- [FXID](fxid.md)
- [Render pipeline](render.md)
Все значения хранятся в **littleendian** порядке (платформа x86/Win32).
## 1. Базовые двоичные правила
## 5.2. Выравнивание
1. Все форматы в этой подсистеме little-endian.
2. Внутри NRes данные ресурсов выравниваются по 8 байт.
3. Внутри payload таблиц padding между записями обычно отсутствует: записи идут подряд по stride.
- **NResресурсы:** данные каждого ресурса внутри NResархива выровнены по границе **8 байт** (0padding).
- **Внутренняя структура ресурсов:** таблицы Res1/Res2/Res7/Res13 не имеют межзаписевого выравнивания — записи идут подряд.
- **Vertex streams:** stride'ы фиксированы (12/4/8 байт) — вершинные данные идут подряд без паддинга.
## 2. Быстрая карта stride'ов
## 5.3. Размеры записей на диске
| Ресурс | Запись | Stride |
|---|---|---:|
| Res1 | Node | 38 |
| Res2 | Slot | 68 (после header `0x8C`) |
| Res3 | Position | 12 |
| Res4 | Normal | 4 |
| Res5 | UV0 | 4 |
| Res6 | Index | 2 |
| Res7 | Tri descriptor | 16 |
| Res8 | Animation key | 24 |
| Res13 | Batch | 20 |
| Res19 | Animation map | 2 |
| Ресурс | Запись | Размер (байт) | Stride |
|--------|-----------|---------------|-------------------------|
| Res1 | Node | 38 | 38 (19×u16) |
| Res2 | Slot | 68 | 68 |
| Res3 | Position | 12 | 12 (3×f32) |
| Res4 | Normal | 4 | 4 (4×s8) |
| Res5 | UV0 | 4 | 4 (2×s16) |
| Res6 | Index | 2 | 2 (u16) |
| Res7 | TriDesc | 16 | 16 |
| Res8 | AnimKey | 24 | 24 |
| Res10 | StringRec | переменный | `4 + (len ? len+1 : 0)` |
| Res13 | Batch | 20 | 20 |
| Res19 | AnimMap | 2 | 2 (u16) |
| Res15 | VtxStr | 8 | 8 |
| Res16 | VtxStr | 8 | 8 (2×4) |
| Res18 | VtxStr | 4 | 4 |
## 3. Декодирование ключевых потоков
## 5.4. Вычисление количества элементов
## 3.1. Позиции (Res3)
Количество записей вычисляется из размера ресурса:
`float3`, stride `12`.
```
count = resource_data_size / record_stride
## 3.2. Нормали (Res4)
`int8[4]`, используются первые 3 компоненты:
```text
n = clamp(s8 / 127.0, -1..1)
```
Например:
## 3.3. UV (Res5)
- `vertex_count = res3_size / 12`
- `index_count = res6_size / 2`
- `batch_count = res13_size / 20`
- `slot_count = (res2_size - 140) / 68`
- `node_count = res1_size / 38`
- `tri_desc_count = res7_size / 16`
- `anim_key_count = res8_size / 24`
- `anim_map_count = res19_size / 2`
`int16[2]`:
Для Res10 нет фиксированного stride: нужно последовательно проходить записи `u32 len` + `(len ? len+1 : 0)` байт.
## 5.5. Идентификация ресурсов в NRes
Ресурсы модели идентифицируются по полю `type` (смещение 0) в каталожной записи NRes. Загрузчик использует `niFindRes(archive, type, subtype)` для поиска, где `type` — число (1, 2, 3, ... 20), а `subtype` (byte) — уточнение (из аргумента загрузчика).
## 5.6. Минимальный набор для рендера
Для статической модели без анимации достаточно:
| Ресурс | Обязательность |
|--------|------------------------------------------------|
| Res1 | Да |
| Res2 | Да |
| Res3 | Да |
| Res4 | Рекомендуется |
| Res5 | Рекомендуется |
| Res6 | Да |
| Res7 | Для коллизии |
| Res13 | Да |
| Res10 | Желательно (узловые имена/поведенческие ветки) |
| Res8 | Нет (анимация) |
| Res19 | Нет (анимация) |
| Res15 | Нет |
| Res16 | Нет |
| Res18 | Нет |
| Res20 | Нет |
## 5.7. Сводка алгоритмов декодирования
### Позиции (Res3)
```python
def decode_position(data, vertex_index):
offset = vertex_index * 12
x = struct.unpack_from('<f', data, offset)[0]
y = struct.unpack_from('<f', data, offset + 4)[0]
z = struct.unpack_from('<f', data, offset + 8)[0]
return (x, y, z)
```text
u = s16 / 1024.0
v = s16 / 1024.0
```
### Нормали (Res4)
## 3.4. Animation key (Res8)
```python
def decode_normal(data, vertex_index):
offset = vertex_index * 4
nx = struct.unpack_from('<b', data, offset)[0] # int8
ny = struct.unpack_from('<b', data, offset + 1)[0]
nz = struct.unpack_from('<b', data, offset + 2)[0]
# nw = data[offset + 3] # не используется
return (
max(-1.0, min(1.0, nx / 127.0)),
max(-1.0, min(1.0, ny / 127.0)),
max(-1.0, min(1.0, nz / 127.0)),
)
`pos(float3) + time(float) + quat(int16x4)`:
```text
q = s16 / 32767.0
```
### UVкоординаты (Res5)
## 4. Практический reader-контракт
```python
def decode_uv(data, vertex_index):
offset = vertex_index * 4
u = struct.unpack_from('<h', data, offset)[0] # int16
v = struct.unpack_from('<h', data, offset + 2)[0]
return (u / 1024.0, v / 1024.0)
```
Для runtime-совместимого чтения модели:
### Кодирование нормали (для экспортёра)
1. Найти нужные ресурсы по `type_id` в NRes.
2. Проверить `size/stride`-инварианты.
3. Проверить диапазоны ссылок:
- slot -> batch/triangles;
- batch -> indices;
- indices -> vertices;
- anim_map -> anim_keys.
4. Неизвестные поля и неизвестные ресурсы сохранять через copy-through.
```python
def encode_normal(nx, ny, nz):
return (
max(-128, min(127, int(round(nx * 127.0)))),
max(-128, min(127, int(round(ny * 127.0)))),
max(-128, min(127, int(round(nz * 127.0)))),
0 # nw = 0 (безопасное значение)
)
```
## 5. Практический writer-контракт
### Кодирование UV (для экспортёра)
1. Пересчитывать только явно вычислимые поля.
2. Не нормализовать opaque-данные без уверенной спецификации.
3. При roundtrip неизмененных данных требовать byte-identical результат.
4. Для новых ассетов фиксировать отдельную политику «генерация vs preserve».
```python
def encode_uv(u, v):
return (
max(-32768, min(32767, int(round(u * 1024.0)))),
max(-32768, min(32767, int(round(v * 1024.0))))
)
```
## 6. Runtime-связка материалов и текстур
### Строки узлов (Res10)
Канонический путь резолва:
```python
def parse_res10_for_nodes(buf: bytes, node_count: int) -> list[str | None]:
out = []
off = 0
for _ in range(node_count):
ln = struct.unpack_from('<I', buf, off)[0]
off += 4
if ln == 0:
out.append(None)
continue
raw = buf[off:off + ln + 1] # len + '\0'
out.append(raw[:-1].decode('ascii', errors='replace'))
off += ln + 1
return out
```
1. Модель -> wear-таблица (`*.wea`).
2. Wear-слот -> material name.
3. Material -> текущая фаза -> `textureName`.
4. `Texm` ищется в `Textures.lib` (или lightmap-библиотеке для lightmap-ветки).
### Ключ анимации (Res8) и mapping (Res19)
Fallback:
```python
def decode_anim_key24(buf: bytes, idx: int):
o = idx * 24
px, py, pz, t = struct.unpack_from('<4f', buf, o)
qx, qy, qz, qw = struct.unpack_from('<4h', buf, o + 16)
s = 1.0 / 32767.0
return (px, py, pz), t, (qx * s, qy * s, qz * s, qw * s)
```
- материал: `DEFAULT`, затем индекс `0`;
- текстура/lightmap: fallback-слот движка.
### Эффектный поток (FXID)
## 7. Что уже закрыто для 1:1
```python
FX_CMD_SIZE = {1:224,2:148,3:200,4:204,5:112,6:4,7:208,8:248,9:208,10:208}
1. Бинарный контракт базовых MSH таблиц.
2. Контракт animation sampling (`Res8 + Res19`).
3. Контракт MAT0/WEAR/Texm на уровне чтения и применения в кадре.
4. Формат FXID-контейнера, командный поток и fixed command sizes.
5. Валидация на retail-корпусе через `tools/msh_doc_validator.py` (0 ошибок/предупреждений).
def parse_fx_payload(raw: bytes):
cmd_count = struct.unpack_from('<I', raw, 0)[0]
ptr = 0x3C
cmds = []
for _ in range(cmd_count):
w = struct.unpack_from('<I', raw, ptr)[0]
op = w & 0xFF
enabled = (w >> 8) & 1
size = FX_CMD_SIZE[op]
cmds.append((op, enabled, ptr, size))
ptr += size
if ptr != len(raw):
raise ValueError('tail bytes after command stream')
return cmds
```
## 8. Статус покрытия и что осталось до 100%
### Texm (header + mips + Page)
```python
def parse_texm(raw: bytes):
magic, w, h, mips, f4, f5, unk6, fmt = struct.unpack_from('<8I', raw, 0)
assert magic == 0x6D786554 # 'Texm'
bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444) else 4)
pix_sum = 0
mw, mh = w, h
for _ in range(mips):
pix_sum += mw * mh
mw = max(1, mw >> 1)
mh = max(1, mh >> 1)
off = 32 + (1024 if fmt == 0 else 0) + bpp * pix_sum
page = None
if off + 8 <= len(raw) and raw[off:off+4] == b'Page':
n = struct.unpack_from('<I', raw, off + 4)[0]
page = [struct.unpack_from('<4h', raw, off + 8 + i * 8) for i in range(n)]
return (w, h, mips, fmt, f4, f5, unk6, page)
```
---
# Часть 6. Остаточные семантические вопросы
Пункты ниже **не блокируют 1:1-парсинг/рендер/интерполяцию** (все бинарные структуры уже определены), но их человеко‑читаемая трактовка может быть уточнена дополнительно.
## 6.1. Batch table — смысл `unk4/unk6/unk14`
Физическое расположение полей известно, но доменное имя/назначение не зафиксировано:
- `unk4` (`+0x04`)
- `unk6` (`+0x06`)
- `unk14` (`+0x0E`)
## 6.2. Node flags и имена групп
- Биты в `Res1.hdr0` используются в ряде рантайм‑веток, но их «геймдизайн‑имена» неизвестны.
- Для groupиндекса `0..4` не найдено текстовых label'ов в ресурсах; для совместимости нужно сохранять числовой индекс как есть.
## 6.3. Slot tail `unk30..unk40`
Хвост слота (`+0x30..+0x43`, `5×uint32`) стабильно присутствует в формате, но движок не делает явной семантической декомпозиции этих пяти слов в path'ах загрузки/рендера/коллизии.
## 6.4. Effect command payload semantics
Container/stream формально полностью восстановлен (header, opcode, размеры, инстанцирование). Остаётся необязательная задача: дать «человеко‑читаемые» имена каждому полю внутри payload конкретных opcode.
## 6.5. Поля `TexmHeader.flags4/flags5/unk6`
Бинарный layout и декодер известны, но значения этих трёх полей в контенте используются контекстно; для 1:1 достаточно хранить/восстанавливать их без модификации.
## 6.6. Что пока не хватает для полноценного обратного экспорта (`OBJ -> MSH/NRes`)
Ниже перечислено то, что нужно закрыть для **lossless round-trip** и 1:1поведения при импорте внешней геометрии обратно в формат игры.
### A) Неполная «авторская» семантика бинарных таблиц
1. `Res2` header (`первые 0x8C`): не зафиксированы все поля и правила их вычисления при генерации нового файла (а не copy-through из оригинала).
2. `Res7` tri-descriptor: для 16байтной записи декодирован базовый каркас, но остаётся неформализованной часть служебных бит/полей, нужных для стабильной генерации adjacency/служебной топологии.
3. `Res13` поля `unk4/unk6/unk14`: для парсинга достаточно, но для генерации «канонических» значений из голого `OBJ` правила не определены.
4. `Res2` slot tail (`unk30..unk40`): семантика не разложена, поэтому при экспорте новых ассетов нет детерминированной формулы заполнения.
### B) Анимационный path ещё не закрыт как writer
1. Нужен полный writer для `Res8/Res19`:
- точная спецификация байтового формата на запись;
- правила генерации mapping (`Res19`) по узлам/кадрам;
- жёсткая фиксация округления как в x87 path (включая edge-case на границах кадра).
2. Правила биндинга узлов/строк (`Res10`) и `slotFlags` к runtimeсущностям пока описаны частично и требуют формализации именно для импорта новых данных.
### C) Материалы, текстуры, эффекты для «полного ассета»
1. Для `Texm` не завершён writer, покрывающий все используемые режимы (включая palette path, mip-chain, `Page`, и правила заполнения служебных полей).
2. Для `FXID` известен контейнер/длины команд, но не завершена field-level семантика payload всех opcode для генерации новых эффектов, эквивалентных оригинальному пайплайну.
3. Экспорт только `OBJ` покрывает геометрию; для игрового ассета нужен sidecar-слой (материалы/текстуры/эффекты/анимация), иначе импорт неизбежно неполный.
### D) Что это означает на практике
1. `OBJ -> MSH` сейчас реалистичен как **ограниченный static-экспорт** (позиции/индексы/часть batch/slot структуры).
2. `OBJ -> полноценный игровой ресурс` (без потерь, с поведением 1:1) пока недостижим без закрытия пунктов A/B/C.
3. До закрытия пунктов A/B/C рекомендуется использовать режим:
- геометрия экспортируется из `OBJ`;
- неизвестные/служебные поля берутся copy-through из референсного оригинального ассета той же структуры.
1. Полная field-level семантика части служебных полей:
- `Batch20` opaque-поля;
- хвостовые служебные поля slot-записей;
- часть флагов узлов/групп.
2. Полный writer-путь для авторинга новых анимированных ассетов (не только roundtrip существующих).
3. Полная формализация семантики FX payload полей по каждому opcode для генерации новых эффектов, а не только для корректного чтения/исполнения.
4. Полный канонический writer `Texm` для всех редких форматов и edge-case комбинаций служебных флагов.
5. Сквозной «импорт внешнего ассета -> игровой пакет» с формальной спецификацией sidecar-метаданных (материал/эффект/анимация).

View File

@@ -13,12 +13,27 @@
7. [Render pipeline](render.md) — полный процесс рендера кадра.
8. [Terrain + map loading](terrain-map-loading.md) — ландшафт, шейдинг и привязка к миру.
9. [3D implementation notes](msh-notes.md) — контрольные заметки и открытые вопросы.
10. [Documentation coverage audit](coverage-audit.md) — сводка покрытия и оставшиеся блокеры.
## Связанные спецификации
- [NRes / RsLi](nres.md)
- [NRes](nres.md)
- [RsLi](rsli.md)
## Принцип декомпозиции
- Форматы и контейнеры документируются отдельно, чтобы их можно было верифицировать и править независимо.
- Runtime-пайплайн вынесен в отдельный документ, потому что пересекает несколько DLL и не является форматом на диске.
- Runtime-пайплайн вынесен в отдельный документ, потому что пересекает несколько runtime-подсистем и не является форматом на диске.
## Статус покрытия и что осталось до 100%
Закрыто:
1. Документация декомпозирована по объектам: geometry, animation, material, texture, wear, fx, render, terrain.
2. Форматные инварианты ключевых 3D-ресурсов проверяются автоматическими валидаторами на retail-корпусе.
Осталось:
1. Полный сквозной writer-путь для генерации новых игровых ассетов без copy-through зависимостей.
2. Полный паритетный рендер-тест (эталонные кадры оригинала vs новый рендер) на расширенном наборе моделей/материалов/FX.
3. Полное покрытие соседних геймплейных подсистем (`AI`, `Behavior`, `Missions`, `Control`, `UI`, `Sound`, `Network`) до уровня точных форматов и runtime-контрактов.

View File

@@ -1,5 +1,28 @@
# Network system
Документ описывает сетевую подсистему: протокол обмена, синхронизацию состояния и сетевую архитектуру (client-server/P2P).
`Network` — подсистема синхронизации состояния игры между узлами (мультиплеер/обмен состоянием).
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга `Net.dll`.
## 1. Роль
1. Транспортирует игровые события и state-delta.
2. Синхронизирует критичные объекты мира и таймеры.
3. Обеспечивает согласованность simulation между участниками.
## 2. Минимальный контракт для 1:1
1. Детеминированная сериализация сетевых сообщений.
2. Согласованная обработка порядка/потерь/повторов пакетов.
3. Единая политика authority и коррекции расхождений.
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- определено место сетевого слоя в общей архитектуре движка.
Осталось:
1. Полная спецификация wire-протокола (header, message types, payload layout).
2. Полный контракт handshake/session lifecycle.
3. Формальные правила resync/rollback/correction.
4. Набор сетевых parity-тестов на контролируемой потере/задержке.

View File

@@ -1,718 +1,189 @@
# Форматы игровых ресурсов
# NRes
## Обзор
`NRes` — базовый контейнер ресурсов движка Parkan: Iron Strategy.
Страница фиксирует формат на диске и runtime-контракт чтения/поиска/сохранения в высокоуровневом виде, без привязки к внутренним адресам и именам из дизассемблера.
Библиотека `Ngi32.dll` реализует два различных формата архивов ресурсов:
Связанная страница:
1. **NRes** — основной формат архива ресурсов, используемый через API `niOpenResFile` / `niCreateResFile`. Каталог файлов расположен в **конце** файла. Поддерживает создание, редактирование, добавление и удаление записей.
- [RsLi](rsli.md)
2. **RsLi** — формат библиотеки ресурсов, используемый через API `rsOpenLib` / `rsLoad`. Таблица записей расположена **в начале** файла (сразу после заголовка) и зашифрована XOR-шифром. Поддерживает несколько методов сжатия. Только чтение.
## 1. Назначение
---
`NRes` используется как универсальный архив:
## Часть 1. Формат NRes
- 3D-модели (`*.msh`, `*.rlb`);
- текстуры (`Texm`);
- материалы (`MAT0`);
- эффекты (`FXID`);
- миссионные и служебные ресурсы.
### 1.1. Общая структура файла
Формат поддерживает:
```
┌──────────────────────────┐ Смещение 0
│ Заголовок (16 байт) │
├──────────────────────────┤ Смещение 16
│ │
│ Данные ресурсов │
│ (выровнены по 8 байт) │
│ │
├──────────────────────────┤ Смещение = total_size - entry_count × 64
│ Каталог записей │
│ (entry_count × 64 байт) │
└──────────────────────────┘ Смещение = total_size
- чтение;
- поиск по имени;
- редактирование (add/replace/remove);
- полную пересборку архива.
## 2. Общий layout файла
```text
[Header: 16]
[Data region: variable, 8-byte aligned chunks]
[Directory: entry_count * 64, всегда в конце файла]
```
### 1.2. Заголовок файла (16 байт)
Критично: каталог всегда расположен в конце файла.
| Смещение | Размер | Тип | Значение | Описание |
| -------- | ------ | ------- | ------------------- | ------------------------------------ |
| 0 | 4 | char[4] | `NRes` (0x4E526573) | Магическая сигнатура (little-endian) |
| 4 | 4 | uint32 | `0x00000100` (256) | Версия формата (1.0) |
| 8 | 4 | int32 | — | Количество записей в каталоге |
| 12 | 4 | int32 | — | Полный размер файла в байтах |
## 3. Заголовок (16 байт)
**Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство** с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется.
Все значения little-endian.
### 1.3. Положение каталога в файле
| Offset | Size | Type | Значение |
|---:|---:|---|---|
| 0 | 4 | char[4] | `NRes` |
| 4 | 4 | u32 | `0x00000100` (версия 1.0) |
| 8 | 4 | i32 | `entry_count` (должен быть `>= 0`) |
| 12 | 4 | u32 | `total_size` (должен быть равен фактическому размеру файла) |
Каталог располагается в самом конце файла. Его смещение вычисляется по формуле:
Производные значения:
```
directory_offset = total_size - entry_count × 64
```
- `directory_size = entry_count * 64`;
- `directory_offset = total_size - directory_size`.
Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом.
Ограничения:
### 1.4. Запись каталога (64 байта)
- `directory_offset >= 16`;
- `directory_offset + directory_size == total_size`.
Каждая запись каталога занимает ровно **64 байта** (0x40):
## 4. Запись каталога (64 байта)
| Смещение | Размер | Тип | Описание |
| -------- | ------ | -------- | ------------------------------------------------- |
| 0 | 4 | uint32 | Тип / идентификатор ресурса |
| 4 | 4 | uint32 | Атрибут 1 (например, формат, дата, категория) |
| 8 | 4 | uint32 | Атрибут 2 (например, подтип, метка времени) |
| 12 | 4 | uint32 | Размер данных ресурса в байтах |
| 16 | 4 | uint32 | Атрибут 3 (дополнительный параметр) |
| 20 | 36 | char[36] | Имя файла (null-terminated, макс. 35 символов) |
| 56 | 4 | uint32 | Смещение данных от начала файла |
| 60 | 4 | uint32 | Индекс сортировки (для двоичного поиска по имени) |
| Offset | Size | Type | Поле |
|---:|---:|---|---|
| 0 | 4 | u32 | `type_id` |
| 4 | 4 | u32 | `attr1` |
| 8 | 4 | u32 | `attr2` |
| 12 | 4 | u32 | `size` (размер payload) |
| 16 | 4 | u32 | `attr3` |
| 20 | 36 | char[36] | `name_raw` (C-строка) |
| 56 | 4 | u32 | `data_offset` |
| 60 | 4 | u32 | `sort_index` |
#### Поле «Имя файла» (смещение 20, 36 байт)
### 4.1. Имя ресурса (`name_raw`)
- Максимальная длина имени: **35 символов** + 1 байт null-терминатор.
- При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов).
- Поиск по имени выполняется **без учёта регистра** (`_strcmpi`).
Контракт:
#### Поле «Индекс сортировки» (смещение 60)
- максимум 35 полезных байт + NUL;
- допускается ровно один терминатор внутри 36-байтового поля;
- имя сравнивается регистронезависимо по ASCII-правилу (`A..Z` -> `a..z`).
Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260` с помощью **пузырьковой сортировки** по именам.
Для writer/editor:
**Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии.
- запрещено писать NUL внутри полезной части имени;
- запрещены имена длиной > 35 байт.
#### Поле «Смещение данных» (смещение 56)
### 4.2. Диапазон данных (`data_offset`, `size`)
Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`.
Для каждой записи:
### 1.5. Выравнивание данных
- `data_offset >= 16`;
- `data_offset + size <= directory_offset`.
При добавлении ресурса его данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**:
Практически (канонический writer): каждый payload начинается с 8-байтного выравнивания.
```c
padding = ((data_size + 7) & ~7) - data_size;
// Если padding > 0, записываются нулевые байты
```
## 5. Таблица сортировки (`sort_index`)
Таким образом, каждый блок данных начинается с адреса, кратного 8.
`sort_index` задает перестановку «отсортированный список -> исходный индекс записи».
При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога.
Пусть:
### 1.6. Создание файла (API `niCreateResFile`)
- `entries[i]` — i-я запись каталога в исходном порядке;
- `P` — массив индексов `0..entry_count-1`, отсортированный по `entries[idx].name` (ASCII case-insensitive).
При создании нового файла:
Тогда в канонической записи:
1. Если файл уже существует и содержит корректный NRes-архив, существующий каталог считывается с конца файла, а файл усекается до начала каталога.
2. Если файл пуст или не является NRes-архивом, создаётся новый с пустым каталогом. Поля `entry_count = 0`, `total_size = 16`.
- `entries[i].sort_index = P[i]`.
При закрытии файла (`sub_100122D0`):
Это именно таблица для бинарного поиска по имени, а не «ранг текущей записи».
1. Заголовок переписывается в начало файла (16 байт).
2. Вычисляется `total_size = data_end_offset + entry_count × 64`.
3. Индексы сортировки пересчитываются.
4. Каталог записей записывается в конец файла.
## 6. Поиск по имени
### 1.7. Режимы сортировки каталога
Алгоритм поиска:
Функция `sub_10012560` поддерживает 12 режимов сортировки (011):
1. Выполнить бинарный поиск по диапазону `i in [0, entry_count)`.
2. На шаге `i` взять `target = entries[i].sort_index`.
3. Сравнить искомое имя с `entries[target].name` (ASCII case-insensitive).
4. При совпадении вернуть `target`.
| Режим | Порядок сортировки |
| ----- | --------------------------------- |
| 0 | Без сортировки (сброс) |
| 1 | По атрибуту 1 (смещение 4) |
| 2 | По атрибуту 2 (смещение 8) |
| 3 | По (атрибут 1, атрибут 2) |
| 4 | По типу ресурса (смещение 0) |
| 5 | По (тип, атрибут 1) |
| 6 | По (тип, атрибут 1) — идентичен 5 |
| 7 | По (тип, атрибут 1, атрибут 2) |
| 8 | По имени (регистронезависимо) |
| 9 | По (тип, имя) |
| 10 | По (атрибут 1, имя) |
| 11 | По (атрибут 2, имя) |
Fail-safe поведение:
### 1.8. Операция `niOpenResFileEx` — флаги открытия
- если `sort_index` некорректен (выход за диапазон), реализация должна перейти на линейный fallback по всем записям;
- fallback использует то же ASCII case-insensitive сравнение.
Второй параметр — битовые флаги:
## 7. Каноническая пересборка архива
| Бит | Маска | Описание |
| --- | ----- | ----------------------------------------------------------------------------------- |
| 0 | 0x01 | Sequential scan hint (`FILE_FLAG_SEQUENTIAL_SCAN` вместо `FILE_FLAG_RANDOM_ACCESS`) |
| 1 | 0x02 | Открыть для записи (read-write). Без флага — только чтение |
| 2 | 0x04 | Пометить файл как «кэшируемый» (не выгружать при refcount=0) |
| 3 | 0x08 | Raw-режим: не проверять заголовок NRes, трактовать весь файл как единый ресурс |
Канонический writer выполняет:
### 1.9. Виртуальное касание страниц
1. Пишет заглушку заголовка (16 байт).
2. Пишет payload всех записей в текущем порядке.
3. После каждого payload добавляет 0-padding до кратности 8.
4. Пересчитывает `sort_index` через сортировку имен.
5. Дописывает каталог (`entry_count * 64`).
6. Пересчитывает и записывает `total_size`.
Функция `sub_100197D0` выполняет «касание» страниц памяти для принудительной загрузки из memory-mapped файла. Она обходит адресное пространство с шагом 4096 байт (размер страницы), начиная с 0x10000 (64 КБ):
Итоговый файл должен удовлетворять всем ограничениям из разделов 35.
```
for (result = 0x10000; result < size; result += 4096);
```
## 8. Режим `raw` (совместимость инструментов)
Вызывается при чтении данных ресурса с флагом `a3 != 0` для предзагрузки данных в оперативную память.
Для служебных инструментов допускается `raw_mode`:
---
- любой бинарный файл трактуется как один «сырой» ресурс;
- возвращается одна запись (`name = RAW`, `data_offset = 0`, `size = len(file)`).
## Часть 2. Формат RsLi
Этот режим не является форматом `NRes` на диске, это только режим открытия.
### 2.1. Общая структура файла
## 9. Контрольные инварианты
```
┌───────────────────────────────┐ Смещение 0
│ Заголовок файла (32 байта) │
├───────────────────────────────┤ Смещение 32
│ Таблица записей (зашифрована)│
│ (entry_count × 32 байт) │
├───────────────────────────────┤ Смещение 32 + entry_count × 32
│ │
│ Данные ресурсов │
│ │
├───────────────────────────────┤
│ [Опциональный трейлер — 6 б] │
└───────────────────────────────┘
```
Минимальный набор проверок при чтении:
### 2.2. Заголовок файла (32 байта)
1. `magic == "NRes"`.
2. `version == 0x100`.
3. `entry_count >= 0`.
4. `header.total_size == file_size`.
5. Каталог находится в конце файла.
6. Для каждой записи диапазон данных не пересекает каталог.
7. Имя корректно C-терминировано и не длиннее 35 байт.
| Смещение | Размер | Тип | Значение | Описание |
| -------- | ------ | ------- | ----------------- | --------------------------------------------- |
| 0 | 2 | char[2] | `NL` (0x4C4E) | Магическая сигнатура |
| 2 | 1 | uint8 | `0x00` | Зарезервировано (должно быть 0) |
| 3 | 1 | uint8 | `0x01` | Версия формата |
| 4 | 2 | int16 | — | Количество записей (sign-extended при чтении) |
| 6 | 8 | — | — | Зарезервировано / не используется |
| 14 | 2 | uint16 | `0xABBA` или иное | Флаг предсортировки (см. ниже) |
| 16 | 4 | — | — | Зарезервировано |
| 20 | 4 | uint32 | — | **Начальное состояние XOR-шифра** (seed) |
| 24 | 8 | — | — | Зарезервировано |
Минимальный набор проверок при записи:
#### Флаг предсортировки (смещение 14)
1. Все имена <= 35 байт и без внутренних NUL.
2. `sort_index` формирует валидную перестановку `0..N-1`.
3. Все паддинги между payload состоят из нулевых байт.
4. `total_size` равен фактической длине выходного файла.
- Если `*(uint16*)(header + 14) == 0xABBA` — движок **не строит** таблицу индексов в памяти. Значения `entry[i].sort_to_original` используются **как есть** (и для двоичного поиска, и как XORключ для данных).
- Если значение **отлично от 0xABBA** — после загрузки выполняется **пузырьковая сортировка** имён и строится перестановка `sort_to_original[]`, которая затем **записывается в `entry[i].sort_to_original`**, перетирая значения из файла. Именно эта перестановка далее используется и для поиска, и как XORключ (младшие 16 бит).
## 10. Эмпирическая проверка на retail-корпусе
### 2.3. XOR-шифр таблицы записей
Валидация на полном наборе `testdata/Parkan - Iron Strategy`:
Таблица записей начинается со смещения 32 и зашифрована поточным XOR-шифром. Ключ инициализируется из DWORD по смещению 20 заголовка.
- найдено `120` архивов `NRes`;
- roundtrip `unpack -> repack -> byte-compare`: `120/120` совпали побайтно;
- критических расхождений формата не обнаружено.
#### Начальное состояние
Инструмент:
```
seed = *(uint32*)(header + 20)
lo = seed & 0xFF // Младший байт
hi = (seed >> 8) & 0xFF // Второй байт
```
- `tools/archive_roundtrip_validator.py`
#### Алгоритм дешифровки (побайтовый)
## 11. Статус покрытия и что осталось до 100%
Для каждого зашифрованного байта `encrypted[i]`, начиная с `i = 0`:
Закрыто:
```
step 1: lo = hi ^ ((lo << 1) & 0xFF) // Сдвиг lo влево на 1, XOR с hi
step 2: decrypted[i] = lo ^ encrypted[i] // Расшифровка байта
step 3: hi = lo ^ ((hi >> 1) & 0xFF) // Сдвиг hi вправо на 1, XOR с lo
```
- формат заголовка/каталога;
- правила поиска;
- каноническая пересборка;
- строгие инварианты валидатора;
- побайтовый roundtrip на retail-корпусе.
**Пример реализации:**
Осталось до полного 100% архитектурного покрытия движка:
```python
def decrypt_rs_entries(encrypted_data: bytes, seed: int) -> bytes:
lo = seed & 0xFF
hi = (seed >> 8) & 0xFF
result = bytearray(len(encrypted_data))
for i in range(len(encrypted_data)):
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
result[i] = lo ^ encrypted_data[i]
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
return bytes(result)
```
Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи.
### 2.4. Запись таблицы (32 байта, на диске, до дешифровки)
После дешифровки каждая запись имеет следующую структуру:
| Смещение | Размер | Тип | Описание |
| -------- | ------ | -------- | -------------------------------------------------------------- |
| 0 | 12 | char[12] | Имя ресурса (ASCII, обычно uppercase; строка читается до `\0`) |
| 12 | 4 | — | Зарезервировано (движком игнорируется) |
| 16 | 2 | int16 | **Флаги** (метод сжатия и атрибуты) |
| 18 | 2 | int16 | **`sort_to_original[i]` / XORключ** (см. ниже) |
| 20 | 4 | uint32 | **Размер распакованных данных** (`unpacked_size`) |
| 24 | 4 | uint32 | Смещение данных от начала файла (`data_offset`) |
| 28 | 4 | uint32 | Размер упакованных данных в байтах (`packed_size`) |
#### Имена ресурсов
- Поле `name[12]` копируется побайтно. Внутренне движок всегда имеет `\0` сразу после этих 12 байт (зарезервированные 4 байта в памяти принудительно обнуляются), поэтому имя **может быть длиной до 12 символов** даже без `\0` внутри `name[12]`.
- На практике имена обычно **uppercase ASCII**. `rsFind` приводит запрос к верхнему регистру (`_strupr`) и сравнивает побайтно.
- `rsFind` копирует имя запроса `strncpy(..., 16)` и принудительно ставит `\0` в `Destination[15]`, поэтому запрос длиннее 15 символов будет усечён.
#### Поле `sort_to_original[i]` (смещение 18)
Это **не “свойство записи”**, а элемент таблицы индексов, по которой `rsFind` делает двоичный поиск:
- Таблица реализована “внутри записей”: значение берётся как `entry[i].sort_to_original` (где `i` — позиция двоичного поиска), а реальная запись для сравнения берётся как `entry[ sort_to_original[i] ]`.
- Тем же значением (младшие 16 бит) инициализируется XORшифр данных для методов, где он используется (0x20/0x60/0xA0). Поэтому при упаковке/шифровании данных ключ должен совпадать с итоговым `sort_to_original[i]` (см. флаг 0xABBA в разделе 2.2).
Поиск выполняется **двоичным поиском** по этой таблице, с фолбэком на **линейный поиск** если двоичный не нашёл (поведение `rsFind`).
### 2.5. Поле флагов (смещение 16 записи)
Биты поля флагов кодируют метод сжатия и дополнительные атрибуты:
```
Биты [8:5] (маска 0x1E0): Метод сжатия/шифрования
Бит [6] (маска 0x040): Флаг realloc (буфер декомпрессии может быть больше)
```
#### Методы сжатия (биты 85, маска 0x1E0)
| Значение | Hex | Описание |
| -------- | ----- | --------------------------------------- |
| 0x000 | 0x00 | Без сжатия (копирование) |
| 0x020 | 0x20 | Только XOR-шифр |
| 0x040 | 0x40 | LZSS (простой вариант) |
| 0x060 | 0x60 | XOR-шифр + LZSS (простой вариант) |
| 0x080 | 0x80 | LZSS с адаптивным кодированием Хаффмана |
| 0x0A0 | 0xA0 | XOR-шифр + LZSS с Хаффманом |
| 0x100 | 0x100 | Deflate (аналог zlib/RFC 1951) |
Примечание: `rsGetPackMethod()` возвращает `flags & 0x1C0` (без бита 0x20). Поэтому:
- для 0x20 вернётся 0x00,
- для 0x60 вернётся 0x40,
- для 0xA0 вернётся 0x80.
#### Бит 0x40 (выделение +0x12 и последующее `realloc`)
Бит 0x40 проверяется отдельно (`flags & 0x40`). Если он установлен, выходной буфер выделяется с запасом `+0x12` (18 байт), а после распаковки вызывается `realloc` для усечения до точного `unpacked_size`.
Важно: этот же бит входит в код методов 0x40/0x60, поэтому для них поведение “+0x12 и shrink” включено автоматически.
### 2.6. Размеры данных
В каждой записи на диске хранятся оба значения:
- `unpacked_size` (смещение 20) — размер распакованных данных.
- `packed_size` (смещение 28) — размер упакованных данных (байт во входном потоке для выбранного метода).
Для метода 0x00 (без сжатия) обычно `packed_size == unpacked_size`.
`rsGetInfo` возвращает именно `unpacked_size` (то, сколько байт выдаст `rsLoad`).
Практический нюанс для метода `0x100` (Deflate): в реальных игровых данных встречается запись, где `packed_size` указывает на диапазон до `EOF + 1`. Поток успешно декодируется и без последнего байта; это похоже на lookahead-поведение декодера.
### 2.7. Опциональный трейлер медиа (6 байт)
При открытии с флагом `a2 & 2`:
| Смещение от конца | Размер | Тип | Описание |
| ----------------- | ------ | ------- | ----------------------- |
| 6 | 2 | char[2] | Сигнатура `AO` (0x4F41) |
| 4 | 4 | uint32 | Смещение медиа-оверлея |
Если трейлер присутствует, все смещения данных в записях корректируются: `effective_offset = entry_offset + media_overlay_offset`.
---
## Часть 3. Алгоритмы сжатия (формат RsLi)
### 3.1. XOR-шифр данных (метод 0x20)
Алгоритм идентичен XORшифру таблицы записей (раздел 2.3), но начальный ключ берётся из `entry[i].sort_to_original` (смещение 18 записи, младшие 16 бит).
Важно про размер входа:
- В ветке **0x20** движок XORит ровно `unpacked_size` байт (и ожидает, что поток данных имеет ту же длину; на практике `packed_size == unpacked_size`).
- В ветках **0x60/0xA0** XOR применяется к **упакованному** потоку длиной `packed_size` перед декомпрессией.
#### Инициализация
```
key16 = (uint16)entry.sort_to_original // int16 на диске по смещению 18
lo = key16 & 0xFF
hi = (key16 >> 8) & 0xFF
```
#### Дешифровка (псевдокод)
```
for i in range(N): # N = unpacked_size (для 0x20) или packed_size (для 0x60/0xA0)
lo = (hi ^ ((lo << 1) & 0xFF)) & 0xFF
out[i] = in[i] ^ lo
hi = (lo ^ ((hi >> 1) & 0xFF)) & 0xFF
```
### 3.2. LZSS — простой вариант (метод 0x40)
Классический алгоритм LZSS (Lempel-Ziv-Storer-Szymanski) с кольцевым буфером.
#### Параметры
| Параметр | Значение |
| ----------------------------- | ------------------ |
| Размер кольцевого буфера | 4096 байт (0x1000) |
| Начальная позиция записи | 4078 (0xFEE) |
| Начальное заполнение | 0x20 (пробел) |
| Минимальная длина совпадения | 3 |
| Максимальная длина совпадения | 18 (4 бита + 3) |
#### Алгоритм декомпрессии
```
Инициализация:
ring_buffer[0..4095] = 0x20 (заполнить пробелами)
ring_pos = 4078
flags_byte = 0
flags_bits_remaining = 0
Цикл (пока не заполнен выходной буфер И не исчерпан входной):
1. Если flags_bits_remaining == 0:
- Прочитать 1 байт из входного потока → flags_byte
- flags_bits_remaining = 8
Декодировать как:
- Старший бит устанавливается в 0x7F (маркер)
- Оставшиеся 7 бит — флаги текущей группы
Реально в коде: control_word = (flags_byte) | (0x7F << 8)
Каждый бит проверяется сдвигом вправо.
2. Проверить младший бит control_word:
Если бит = 1 (литерал):
- Прочитать 1 байт из входного потока → byte
- ring_buffer[ring_pos] = byte
- ring_pos = (ring_pos + 1) & 0xFFF
- Записать byte в выходной буфер
Если бит = 0 (ссылка):
- Прочитать 2 байта: low_byte, high_byte
- offset = low_byte | ((high_byte & 0xF0) << 4) // 12 бит
- length = (high_byte & 0x0F) + 3 // 4 бита + 3
- Скопировать length байт из ring_buffer[offset...]:
для j от 0 до length-1:
byte = ring_buffer[(offset + j) & 0xFFF]
ring_buffer[ring_pos] = byte
ring_pos = (ring_pos + 1) & 0xFFF
записать byte в выходной буфер
3. Сдвинуть control_word вправо на 1 бит
4. flags_bits_remaining -= 1
```
#### Подробная раскладка пары ссылки (2 байта)
```
Байт 0 (low): OOOOOOOO (биты [7:0] смещения)
Байт 1 (high): OOOOLLLL O = биты [11:8] смещения, L = длина 3
offset = low | ((high & 0xF0) << 4) // Диапазон: 04095
length = (high & 0x0F) + 3 // Диапазон: 318
```
### 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана.
#### Параметры
| Параметр | Значение |
| -------------------------------- | ------------------------------ |
| Размер кольцевого буфера | 4096 байт |
| Начальная позиция записи | **4036** (0xFC4) |
| Начальное заполнение | 0x20 (пробел) |
| Количество листовых узлов дерева | 314 |
| Символы литералов | 0255 (байты) |
| Символы длин | 256313 (длина = символ 253) |
| Начальная длина | 3 (при символе 256) |
| Максимальная длина | 60 (при символе 313) |
#### Дерево Хаффмана
Дерево строится как **адаптивное** (dynamic, self-adjusting):
- **627 узлов**: 314 листовых + 313 внутренних.
- Все листья изначально имеют **вес 1**.
- Корень дерева — узел с индексом 0 (в массиве `parent`).
- После декодирования каждого символа дерево **обновляется** (функция `sub_1001B0AE`): вес узла инкрементируется, и при нарушении порядка узлы **переставляются** для поддержания свойства.
- При достижении суммарного веса **0x8000 (32768)** — все веса **делятся на 2** (с округлением вверх) и дерево полностью перестраивается.
#### Кодирование позиции
Позиция в кольцевом буфере кодируется с помощью **d-кода** (таблица дистанций):
- 8 бит позиции ищутся в таблице `d_code[256]`, определяя базовое значение и количество дополнительных битов.
- Из потока считываются дополнительные биты, которые объединяются с базовым значением.
- Финальная позиция: `pos = (ring_pos 1 decoded_position) & 0xFFF`
**Таблицы инициализации** (d-коды):
```
Таблица базовых значений — byte_100371D0[6]:
{ 0x01, 0x03, 0x08, 0x0C, 0x18, 0x10 }
Таблица дополнительных битов — byte_100371D6[6]:
{ 0x20, 0x30, 0x40, 0x30, 0x30, 0x10 }
```
#### Алгоритм декомпрессии (высокоуровневый)
```
Инициализация:
ring_buffer[0..4095] = 0x20
ring_pos = 4036
Инициализировать дерево Хаффмана (314 листьев, все веса = 1)
Инициализировать таблицы d-кодов
Цикл:
1. Декодировать символ из потока по дереву Хаффмана:
- Начать с корня
- Читать биты, спускаться по дереву (0 = левый, 1 = правый)
- Пока не достигнут лист → символ = лист 627
2. Обновить дерево Хаффмана для декодированного символа
3. Если символ < 256 (литерал):
- ring_buffer[ring_pos] = символ
- ring_pos = (ring_pos + 1) & 0xFFF
- Записать символ в выходной буфер
4. Если символ >= 256 (ссылка):
- length = символ 253
- Декодировать позицию через d-код:
a) Прочитать 8 бит из потока
b) Найти d-код и дополнительные биты по таблице
c) Прочитать дополнительные биты
d) position = (ring_pos 1 full_position) & 0xFFF
- Скопировать length байт из ring_buffer[position...]
5. Если выходной буфер заполнен → завершить
```
### 3.4. XOR + LZSS (методы 0x60 и 0xA0)
Комбинированный метод: сначала XOR-дешифровка, затем LZSS-декомпрессия.
#### Алгоритм
1. Выделить временный буфер размером `compressed_size` (поле из записи, смещение 28).
2. Дешифровать сжатые данные XOR-шифром (раздел 3.1) с ключом из записи во временный буфер.
3. Применить LZSS-декомпрессию (простую или с Хаффманом, в зависимости от конкретного метода) из временного буфера в выходной.
4. Освободить временный буфер.
- **0x60** — XOR + простой LZSS (раздел 3.2)
- **0xA0** — XOR + LZSS с Хаффманом (раздел 3.3)
#### Начальное состояние XOR для данных
При комбинированном методе seed берётся из поля по смещению 20 записи (4-байтный). Однако ключ обрабатывается как 16-битный: `lo = seed & 0xFF`, `hi = (seed >> 8) & 0xFF`.
### 3.5. Deflate (метод 0x100)
Полноценная реализация алгоритма **Deflate** (RFC 1951) с блочной структурой.
#### Общая структура
Данные состоят из последовательности блоков. Каждый блок начинается с:
- **1 бит** — `is_final`: признак последнего блока
- **2 бита** — `block_type`: тип блока
#### Типы блоков
| block_type | Описание | Функция |
| ---------- | --------------------------- | ---------------- |
| 0 | Без сжатия (stored) | `sub_1001A750` |
| 1 | Фиксированные коды Хаффмана | `sub_1001A8C0` |
| 2 | Динамические коды Хаффмана | `sub_1001AA30` |
| 3 | Зарезервировано (ошибка) | Возвращает код 2 |
#### Блок типа 0 (stored)
1. Отбросить оставшиеся биты до границы байта (выравнивание).
2. Прочитать 16 бит — `LEN` (длина блока).
3. Прочитать 16 бит — `NLEN` (дополнение длины, `NLEN == ~LEN & 0xFFFF`).
4. Проверить: `LEN == (uint16)(~NLEN)`. При несовпадении — ошибка.
5. Скопировать `LEN` байт из входного потока в выходной.
Декомпрессор использует внутренний буфер размером **32768 байт** (0x8000). При заполнении — промежуточная запись результата.
#### Блок типа 1 (фиксированные коды)
Стандартные коды Deflate:
- Литералы/длины: 288 кодов
- 0143: 8-битные коды
- 144255: 9-битные коды
- 256279: 7-битные коды
- 280287: 8-битные коды
- Дистанции: 30 кодов, все 5-битные
Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты).
#### Блок типа 2 (динамические коды)
1. Прочитать 5 бит → `HLIT` (количество литералов/длин 257). Диапазон: 257286.
2. Прочитать 5 бит → `HDIST` (количество дистанций 1). Диапазон: 130.
3. Прочитать 4 бита → `HCLEN` (количество кодов длин 4). Диапазон: 419.
4. Прочитать `HCLEN` × 3 бит — длины кодов для алфавита длин.
5. Построить дерево Хаффмана для алфавита длин (19 символов).
6. С помощью этого дерева декодировать длины кодов для литералов/длин и дистанций.
7. Построить два дерева Хаффмана: для литералов/длин и для дистанций.
8. Декодировать данные.
**Порядок кодов длин** (стандартный Deflate):
```
{ 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15 }
```
Хранится в `dword_10037060`.
#### Валидации
- `HLIT + 257 <= 286` (max 0x11E)
- `HDIST + 1 <= 30` (max 0x1E)
- При нарушении — возвращается ошибка 1.
### 3.6. Метод 0x00 (без сжатия)
Данные копируются «как есть» напрямую из файла. Вызывается через указатель на функцию `dword_1003A1B8` (фактически `memcpy` или аналог).
---
## Часть 4. Внутренние структуры в памяти
### 4.1. Внутренняя структура NRes-архива (opened, 0x68 байт = 104)
```c
struct NResArchive { // Размер: 0x68 (104 байта)
void* vtable; // +0: Указатель на таблицу виртуальных методов
int32_t entry_count; // +4: Количество записей
void* mapped_base; // +8: Базовый адрес mapped view
void* directory_ptr; // +12: Указатель на каталог записей в памяти
char* filename; // +16: Путь к файлу (_strdup)
int32_t ref_count; // +20: Счётчик ссылок
uint32_t last_release_time; // +24: timeGetTime() при последнем Release
// +28..+91: Для raw-режима — встроенная запись (единственный File entry)
NResArchive* next; // +92: Следующий архив в связном списке
uint8_t is_writable; // +100: Файл открыт для записи
uint8_t is_cacheable; // +101: Не выгружать при refcount = 0
};
```
### 4.2. Внутренняя структура RsLi-архива (56 + 64 × N байт)
```c
struct RsLibHeader { // 56 байт (14 DWORD)
uint32_t magic; // +0: 'RsLi' (0x694C7352)
int32_t entry_count; // +4: Количество записей
uint32_t media_offset; // +8: Смещение медиа-оверлея
uint32_t reserved_0C; // +12: 0
HANDLE file_handle_2; // +16: -1 (дополнительный хэндл)
uint32_t reserved_14; // +20: 0
uint32_t reserved_18; // +24: —
uint32_t reserved_1C; // +28: 0
HANDLE mapping_handle_2; // +32: -1
uint32_t reserved_24; // +36: 0
uint32_t flag_28; // +40: (flags >> 7) & 1
HANDLE file_handle; // +44: Хэндл файла
HANDLE mapping_handle; // +48: Хэндл файлового маппинга
void* mapped_view; // +52: Указатель на mapped view
};
// Далее следуют entry_count записей по 64 байта каждая
```
#### Внутренняя запись RsLi (64 байта)
```c
struct RsLibEntry { // 64 байта (16 DWORD)
char name[16]; // +0: Имя (12 из файла + 4 нуля)
int32_t flags; // +16: Флаги (sign-extended из int16)
int32_t sort_index; // +20: sort_to_original[i] (таблица индексов / XORключ)
uint32_t uncompressed_size; // +24: Размер несжатых данных (из поля 20 записи)
void* data_ptr; // +28: Указатель на данные в mapped view
uint32_t compressed_size; // +32: Размер сжатых данных (из поля 28 записи)
uint32_t reserved_24; // +36: 0
uint32_t reserved_28; // +40: 0
uint32_t reserved_2C; // +44: 0
void* loaded_data; // +48: Указатель на декомпрессированные данные
// +52..+63: дополнительные поля
};
```
---
## Часть 5. Экспортируемые API-функции
### 5.1. NRes API
| Функция | Описание |
| ------------------------------ | ------------------------------------------------------------------------- |
| `niOpenResFile(path)` | Открыть NRes-архив (только чтение), эквивалент `niOpenResFileEx(path, 0)` |
| `niOpenResFileEx(path, flags)` | Открыть NRes-архив с флагами |
| `niOpenResInMem(ptr, size)` | Открыть NRes-архив из памяти |
| `niCreateResFile(path)` | Создать/открыть NRes-архив для записи |
### 5.2. RsLi API
| Функция | Описание |
| ------------------------------- | -------------------------------------------------------- |
| `rsOpenLib(path, flags)` | Открыть RsLi-библиотеку |
| `rsCloseLib(lib)` | Закрыть библиотеку |
| `rsLibNum(lib)` | Получить количество записей |
| `rsFind(lib, name)` | Найти запись по имени (→ индекс или 1) |
| `rsLoad(lib, index)` | Загрузить и декомпрессировать ресурс |
| `rsLoadFast(lib, index, flags)` | Быстрая загрузка (без декомпрессии если возможно) |
| `rsLoadPacked(lib, index)` | Загрузить в «упакованном» виде (отложенная декомпрессия) |
| `rsLoadByName(lib, name)` | `rsFind` + `rsLoad` |
| `rsGetInfo(lib, index, out)` | Получить имя и размер ресурса |
| `rsGetPackMethod(lib, index)` | Получить метод сжатия (`flags & 0x1C0`) |
| `ngiUnpack(packed)` | Декомпрессировать ранее загруженный упакованный ресурс |
| `ngiAlloc(size)` | Выделить память (с обработкой ошибок) |
| `ngiFree(ptr)` | Освободить память |
| `ngiGetMemSize(ptr)` | Получить размер выделенного блока |
---
## Часть 6. Контрольные заметки для реализации
### 6.1. Кодировки и регистр
- **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`).
- **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк).
### 6.2. Порядок байт
Все значения хранятся в **little-endian** порядке (платформа x86/Win32).
### 6.3. Выравнивание
- **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами).
- **RsLi**: выравнивание данных не описано в коде (данные идут подряд).
### 6.4. Размер записей на диске
- **NRes**: каталог — **64 байта** на запись, расположен в конце файла.
- **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка).
### 6.5. Кэширование и memory mapping
Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`).
### 6.6. Размер seed XOR
- **Заголовок RsLi**: seed — **4 байта** (DWORD) по смещению 20, но используются только младшие 2 байта (`lo = byte[0]`, `hi = byte[1]`).
- **Запись RsLi**: sort_to_original[i] — **2 байта** (int16) по смещению 18 записи.
- **Данные при комбинированном XOR+LZSS**: seed — **4 байта** (DWORD) из поля по смещению 20 записи, но опять используются только 2 байта.
### 6.7. Эмпирическая проверка на данных игры
- Найдено архивов по сигнатуре: **122** (`NRes`: 120, `RsLi`: 2).
- Выполнен полный roundtrip `unpack -> pack -> byte-compare`: **122/122** архивов совпали побайтно.
- Для `RsLi` в проверенном наборе встретились методы: `0x040` и `0x100`.
Подтверждённые нюансы:
- Для LZSS (метод `0x040`) рабочая раскладка нибблов в ссылке: `OOOO LLLL`, а не `LLLL OOOO`.
- Для Deflate (метод `0x100`) возможен случай `packed_size == фактический_конец + 1` на последней записи файла.
1. Формальная семантика `attr1/attr2/attr3` для всех типов ресурсов (частично вынесена в профильные страницы `msh`, `material`, `texture`, `fxid`, `terrain`).
2. Полная спецификация поведения при не-ASCII именах (в реальных игровых архивах используется ASCII-практика; для Unicode-коллации движок не документирован).
3. Полная спецификация платформенных гарантий атомарной записи (формат данных закрыт, но OS-уровневые гарантии замены файла зависят от платформы и файловой системы).

View File

@@ -75,3 +75,16 @@ CI запускает `render-parity` на каждом push/PR:
Важно: оригинальный движок в CI обычно не запускается.
Эталонные PNG снимаются офлайн и версионируются в репозитории.
## Статус покрытия и что осталось до 100%
Закрыто:
1. Определена метрика сравнения кадров (`mean_abs`, `max_abs`, `changed_ratio`).
2. Описан единый manifest-формат кейсов и CI-процедура.
Осталось:
1. Снять и зафиксировать расширенный эталонный набор кадров оригинала (10-20+ ключевых моделей и режимов).
2. Зафиксировать пороговые критерии pass/fail по каждому классу сцен (статик, анимация, FX, lightmap).
3. Добавить автоматическую публикацию diff-артефактов и регрессионных отчетов в CI.

View File

@@ -151,5 +151,19 @@ void RenderFrame(Scene* scene, Camera* cam, float dt) {
## 10. Статус валидации
- Порядок кадра и подключение `Material.lib / Textures.lib / LightMap.lib` подтверждены текущим runtime-кодом приложения и импортами движковых DLL.
- Порядок кадра и подключение `Material.lib / Textures.lib / LightMap.lib` подтверждены текущей runtime-валидацией проекта.
- Детальные инварианты форматов зафиксированы в `tools/msh_doc_validator.py` и `tools/fxid_abs100_audit.py`.
## 11. Статус покрытия и что осталось до 100%
Закрыто:
1. Высокоуровневый кадр: simulation -> animation -> culling -> material/texture resolve -> mesh draw -> fx -> ui -> present.
2. Связка MSH/MAT0/WEAR/Texm/FXID в едином runtime-процессе.
3. Форматная валидация входных данных на полном retail-корпусе.
Осталось:
1. Полный pixel-parity контур с эталонными кадрами оригинального рендера по набору моделей/сцен.
2. Формализация всех render-state деталей (точные blend/depth/cull/state transitions) для гарантии 1:1 в каждом draw-pass.
3. Полный coverage-пакет по динамическим веткам (FX-heavy кадры, сложные material-режимы, lightmap-комбинации).

230
docs/specs/rsli.md Normal file
View File

@@ -0,0 +1,230 @@
# RsLi
`RsLi` — библиотечный контейнер ресурсов движка Parkan: Iron Strategy с зашифрованной таблицей записей и несколькими методами упаковки данных.
Страница описывает формат и runtime-контракт в высокоуровневом виде, без ссылок на внутренние адреса/функции дизассемблера.
Связанная страница:
- [NRes](nres.md)
## 1. Общая структура файла
```text
[Header: 32]
[Entry table: entry_count * 32, XOR-encrypted]
[Packed payloads]
[Optional trailer: "AO" + overlay:u32]
```
В отличие от `NRes`, таблица записей у `RsLi` расположена в начале файла.
## 2. Заголовок (32 байта)
Все значения little-endian.
| Offset | Size | Type | Поле |
|---:|---:|---|---|
| 0 | 2 | char[2] | `NL` (магия) |
| 2 | 1 | u8 | зарезервировано, в retail = `0` |
| 3 | 1 | u8 | версия, в retail = `1` |
| 4 | 2 | i16 | `entry_count` (должен быть `>= 0`) |
| 14 | 2 | u16 | `presorted_flag` (`0xABBA` = таблица сортировки уже задана) |
| 20 | 4 | u32 | `xor_seed` |
Остальные байты заголовка считаются служебными и должны сохраняться без нормализации.
## 3. Таблица записей (после дешифровки)
Таблица начинается с `offset = 32`, размер `entry_count * 32`.
Каждая запись (32 байта):
| Offset | Size | Type | Поле |
|---:|---:|---|---|
| 0 | 12 | char[12] | `name_raw` (обычно uppercase ASCII, NUL optional) |
| 12 | 4 | bytes | служебный хвост, сохранять как есть |
| 16 | 2 | i16 | `flags` |
| 18 | 2 | i16 | `sort_to_original` |
| 20 | 4 | u32 | `unpacked_size` |
| 24 | 4 | u32 | `data_offset_raw` |
| 28 | 4 | u32 | `packed_size` |
### 3.1. Метод упаковки
`method = flags & 0x1E0`
Поддерживаемые значения:
| Маска | Метод |
|---:|---|
| `0x000` | без сжатия |
| `0x020` | XOR only |
| `0x040` | LZSS |
| `0x060` | XOR + LZSS |
| `0x080` | LZSS + адаптивный Huffman |
| `0x0A0` | XOR + LZSS + адаптивный Huffman |
| `0x100` | raw Deflate (RFC1951) |
Другие значения считаются неподдерживаемыми.
## 4. XOR-дешифрование таблицы и данных
Для таблицы и XOR-методов payload используется один и тот же потоковый XOR-алгоритм.
Ключ:
- `key16 = xor_seed & 0xFFFF` (используются только младшие 16 бит seed).
Состояние:
```text
lo = key16 & 0xFF
hi = key16 >> 8
```
Для каждого байта:
```text
lo = hi XOR ((lo << 1) mod 256)
out = in XOR lo
hi = lo XOR (hi >> 1)
```
## 5. `sort_to_original` и поиск по имени
### 5.1. Режим `presorted_flag == 0xABBA`
`sort_to_original` обязан быть перестановкой `0..entry_count-1` без дубликатов.
### 5.2. Режим без presorted-флага
Слой загрузки строит `sort_to_original` самостоятельно:
- сортирует индексы по `strcmp`-порядку имен (байтовое сравнение);
- записывает эту перестановку в lookup-таблицу.
### 5.3. Поиск
Поиск выполняется бинарным поиском по lookup-таблице:
1. запрос переводится в uppercase ASCII;
2. на шаге бинарного поиска используется индекс `sort_to_original[mid]`;
3. сравнение имен — bytewise (`strcmp`-логика).
Fail-safe:
- при невалидном индексе lookup-таблицы выполняется линейный fallback.
## 6. AO-трейлер и media overlay
Опциональный трейлер в конце файла:
```text
"AO" + overlay:u32
```
Если трейлер присутствует:
- эффективный offset payload: `effective_offset = data_offset_raw + overlay`.
Ограничение:
- `overlay <= file_size`.
## 7. Декодирование payload по методам
## 7.1. Без сжатия (`0x000`)
Берутся первые `unpacked_size` байт из packed-диапазона.
## 7.2. XOR only (`0x020`)
XOR-дешифрование первых `unpacked_size` байт.
## 7.3. LZSS (`0x040`, `0x060`)
Параметры:
- ring buffer: `4096` байт;
- начальное заполнение ring: `0x20`;
- стартовый указатель ring: `0xFEE`;
- control-биты читаются LSB-first.
Правила:
- `bit=1`: literal byte;
- `bit=0`: ссылка из 2 байт
`offset = low | ((high & 0xF0) << 4)`
`length = (high & 0x0F) + 3`.
Для `0x060` XOR применяется на лету к packed-потоку до LZSS-декодирования.
## 7.4. LZSS + адаптивный Huffman (`0x080`, `0x0A0`)
Параметры:
- `N=4096`, `F=60`, `THRESHOLD=2`;
- адаптивное дерево Huffman обновляется по мере декодирования.
Для `0x0A0` XOR применяется на лету к битовому потоку до Huffman/LZSS-декодирования.
## 7.5. Deflate (`0x100`)
Используется raw Deflate-поток (RFC1951).
Важно:
- zlib-обертка (`RFC1950`) не принимается.
## 8. Quirk: Deflate EOF+1
На retail-корпусе встречается один подтвержденный случай, где:
- `effective_offset + packed_size == file_size + 1`.
Совместимое поведение:
- для метода `0x100` допустить чтение `packed_size - 1` байт (если включен режим совместимости);
- в строгом режиме считать это ошибкой.
## 9. Контрольные инварианты
Минимальные проверки:
1. `magic == "NL"`, `reserved == 0`, `version == 1`.
2. `entry_count >= 0`.
3. `table_end <= file_size`.
4. Если `presorted_flag == 0xABBA`, `sort_to_original` — валидная перестановка.
5. `effective_offset + packed_size` не выходит за EOF (кроме разрешенного deflate EOF+1 quirk).
6. Итоговый распакованный размер равен `unpacked_size`.
## 10. Эмпирическая проверка на retail-корпусе
Проверка на полном наборе `testdata/Parkan - Iron Strategy`:
- обнаружено `2` архива `RsLi`;
- roundtrip `unpack -> repack -> byte-compare`: `2/2` совпали побайтно;
- подтвержден ровно один `deflate EOF+1` случай (`sprites.lib`, entry `23`).
Инструменты:
- `tools/archive_roundtrip_validator.py`
- `crates/rsli` tests
## 11. Статус покрытия и что осталось до 100%
Закрыто:
- формат заголовка/таблицы;
- XOR-алгоритм;
- все используемые методы декодирования;
- AO overlay;
- lookup-поиск и fallback;
- retail-валидация и побайтовый roundtrip.
Осталось до полного 100% архитектурного покрытия движка:
1. Полная функциональная семантика битов `flags` вне маски метода (`0x1E0`) для геймплейных подсистем.
2. Канонический writer для авторинга новых архивов со стабильной стратегией выбора методов (`0x080/0x0A0/0x100`) и параметров компрессии.
3. Формализация поведения для не-ASCII имен (на практике архивы используют ASCII-диапазон).

View File

@@ -6,3 +6,13 @@
Эта страница оставлена как совместимый указатель для старых ссылок.
## Статус покрытия и что осталось до 100%
Закрыто:
1. Актуальный runtime-пайплайн централизован в `render.md`.
Осталось:
1. Поддерживать обратную совместимость ссылок при дальнейшей декомпозиции render-документа.

View File

@@ -1,5 +1,32 @@
# Sound system
Документ описывает аудиоподсистему: форматы звуковых ресурсов, воспроизведение эффектов и голосов, а также интеграцию со звуковым API.
`Sound` — подсистема аудио:
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга звуковых модулей движка.
- загрузка и кеширование звуковых ресурсов;
- воспроизведение SFX/voice/music;
- пространственное позиционирование и микширование.
## 1. Архитектурная роль
1. Получает события от gameplay/FX/mission/UI.
2. Резолвит аудиоресурсы через архивные библиотеки.
3. Управляет каналами, приоритетами и жизненным циклом источников звука.
## 2. Минимальный runtime-контракт
1. Стабильный выбор источника и fallback при отсутствии ресурса.
2. Детерминированные правила приоритета при переполнении каналов.
3. Согласованная модель пространственного затухания и панорамирования.
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- место аудио-подсистемы в общем runtime-контуре.
Осталось:
1. Полная спецификация форматов аудио-ресурсов и lookup-таблиц.
2. Полный контракт 2D/3D микширования и лимитов каналов.
3. Правила взаимодействия с FXID-командами, которые инициируют звук.
4. Набор audio parity-тестов (тайминг/громкость/панорама).

View File

@@ -1,170 +1,111 @@
# Terrain + map loading
# Terrain + ArealMap
Документ описывает полный runtime-пайплайн загрузки ландшафта и карты (`Terrain.dll` + `ArealMap.dll`) и требования к toolchain для 1:1 совместимости (чтение, конвертация, редактирование, обратная сборка).
Документ описывает подсистему ландшафта и ареалов мира в движке Parkan: Iron Strategy:
Источник реверса:
- `Land.msh` (terrain-геометрия и вспомогательные таблицы);
- `Land.map` (ареалы и навигационные связи);
- `BuildDat.lst` (категории объектных зон).
- `tmp/disassembler1/Terrain.dll.c`
- `tmp/disassembler1/ArealMap.dll.c`
- `tmp/disassembler2/Terrain.dll.asm`
- `tmp/disassembler2/ArealMap.dll.asm`
Описание дано в высокоуровневом переносимом виде, без ссылок на внутренние адреса и имена из дизассемблера.
Связанные спецификации:
Связанные страницы:
- [NRes / RsLi](nres.md)
- [NRes](nres.md)
- [RsLi](rsli.md)
- [MSH core](msh-core.md)
- [ArealMap](arealmap.md)
- [Render pipeline](render.md)
---
## 1. End-to-End загрузка уровня
## 1. Назначение подсистем
Для каждой карты движок загружает пару файлов:
### 1.1. `Terrain.dll`
- `.../Land.msh`
- `.../Land.map`
Отвечает за:
Высокоуровневый порядок:
- загрузку и хранение terrain-геометрии из `*.msh` (NRes);
- фильтрацию и выборку треугольников для коллизий/трассировки/рендера;
- рендер terrain-примитивов и связанного shading;
- использование микро-текстурного канала (chunk type 18).
1. Открыть `Land.msh` как `NRes`.
2. Прочитать обязательные terrain-chunk'и.
3. Построить runtime-структуры terrain (slots, faces, spatial grid).
4. Открыть `Land.map` как `NRes`.
5. Найти единственный chunk `type=12`.
6. Прочитать ареалы, их связи и cell-grid.
7. Применить инициализацию объектных категорий из `BuildDat.lst`.
Характерные runtime-строки:
## 2. Формат `Land.msh`
- `CLandscape::CLandscape()`
- `Unable to find microtexture mapping chunk`
- `Rendering empty primitive!`
- `Rendering empty primitive2!`
`Land.msh` — обычный `NRes` архив с фиксированным набором terrain-ресурсов.
### 1.2. `ArealMap.dll`
## 2.1. Состав chunk'ов
Отвечает за:
Обязательные типы:
- загрузку геометрии ареалов из `*.map` (NRes, chunk type 12);
- построение связей "ареал <-> соседи/подграфы";
- grid-ускорение по ячейкам карты;
- runtime-доступ к `ISystemArealMap` (интерфейс id `770`) и ареалам (id `771`).
- `1`, `2`, `3`, `4`, `5`, `11`, `18`, `21`
Характерные runtime-строки:
Опциональные типы:
- `SystemArealMap panic: Cannot load ArealMapGeometry`
- `SystemArealMap panic: Cannot find chunk in resource`
- `SystemArealMap panic: ArealMap Cells are empty`
- `SystemArealMap panic: Incorrect ArealMap`
- `14`
---
Наблюдаемый retail-порядок chunk'ов:
## 2. End-to-End загрузка уровня
### 2.1. Имена файлов уровня
В `CLandscape::CLandscape()` базовое имя уровня `levelBase` разворачивается в:
- `levelBase + ".msh"`: terrain-геометрия;
- `levelBase + ".map"`: геометрия ареалов/навигация;
- `levelBase + "1.wea"` и `levelBase + "2.wea"`: weather/материалы.
### 2.2. Порядок инициализации (высокоуровнево)
1. Получение `3DRender` и `3DSound`.
2. Загрузка `MatManager` (`*.wea`), `LightManager`, `CollManager`, `FxManager`.
3. Создание `SystemArealMap` через `CreateSystemArealMap(..., "<level>.map", ...)`.
4. Открытие terrain-библиотеки `niOpenResFile("<level>.msh")`.
5. Загрузка terrain-chunk-ов (см. §3).
6. Построение runtime-границ, grid-ускорителей и рабочих массивов.
Критичные ошибки на любом шаге приводят к `ngiProcessError`/panic.
---
## 3. Формат terrain `*.msh` (NRes)
### 3.1. Используемые chunk type в `Terrain.dll`
Порядок загрузки в `CLandscape::CLandscape()`:
| Порядок | Type | Обяз. | Использование (подтверждено кодом) |
|---|---:|---|---|
| 1 | 3 | да | поток позиций (`stride = 12`) |
| 2 | 4 | да | поток packed normal (`stride = 4`) |
| 3 | 5 | да | UV-поток (`stride = 4`) |
| 4 | 18 | да | microtexture mapping (`stride = 4`) |
| 5 | 14 | нет | опциональный доп. поток (`stride = 4`, отсутствует на части карт) |
| 6 | 21 | да | таблица terrain-face (по 28 байт) |
| 7 | 2 | да | header + slot-таблицы (используются диапазоны face) |
| 8 | 1 | да | node/grid-таблица (stride 38) |
| 9 | 11 | да | доп. индекс/ускоритель для запросов (cell->list) |
Ключевые проверки:
- отсутствие type `18` вызывает `Unable to find microtexture mapping chunk`;
- отсутствие остальных обязательных чанков вызывает `Unable to open file`.
### 3.2. Node/slot структура для terrain
Terrain-код использует те же stride и адресацию, что и core-описание:
- node-запись: `38` байт;
- slot-запись: `68` байт;
- доступ к первому slot-index: `node + 8`;
- tri-диапазон в slot: `slot + 140` (offset 0 внутри slot), `slot + 142` (offset 2).
Это согласуется с [MSH core](msh-core.md) для `Res1/Res2`:
- `Res1`: `uint16[19]` на node;
- `Res2`: header + slot table (`0x8C + N * 0x44`).
### 3.3. Terrain face record (type 21, 28 bytes)
Подтвержденные поля из runtime-декодирования face:
```c
struct TerrainFace28 {
uint32_t flags; // +0
uint8_t materialId; // +4 (читается как byte)
uint8_t auxByte; // +5
uint16_t unk06; // +6
uint16_t i0; // +8 (индекс вершины)
uint16_t i1; // +10
uint16_t i2; // +12
uint16_t n0; // +14 (сосед, 0xFFFF -> нет)
uint16_t n1; // +16
uint16_t n2; // +18
int16_t nx; // +20 packed normal component
int16_t ny; // +22
int16_t nz; // +24
uint8_t edgeClass; // +26 (три 2-бит значения)
uint8_t unk27; // +27
};
```text
[1, 2, 3, 4, 5, 18, 14, 11, 21]
```
`edgeClass` декодируется как:
## 2.2. Stride и атрибуты
- `edge0 = byte26 & 0x3`
- `edge1 = (byte26 >> 2) & 0x3`
- `edge2 = (byte26 >> 4) & 0x3`
| Type | Назначение | Stride |
|---:|---|---:|
| 1 | node/slot матрица | 38 |
| 3 | позиции вершин | 12 |
| 4 | нормали (packed) | 4 |
| 5 | UV (packed) | 4 |
| 11 | cell-ускоритель | 4 |
| 14 | доп. поток | 4 |
| 18 | доп. поток | 4 |
| 21 | terrain face | 28 |
### 3.4. Маски флагов face
Общее правило для этих chunk'ов:
Во многих запросах применяется фильтр:
- `attr1 == size / stride`
- `attr3 == stride`
```c
(faceFlags & requiredMask) == requiredMask &&
(faceFlags | ~forbiddenMask) == ~forbiddenMask
```
## 2.3. Type `2`: slot table
Эквивалентно: "все required-биты выставлены, forbidden-биты отсутствуют".
`type=2` содержит:
Подтверждено активное использование битов:
- заголовок `0x8C` байт;
- затем таблицу slots по `68` байт.
- `0x8` (особая обработка в трассировке)
- `0x2000`
- `0x20000`
- `0x100000`
- `0x200000`
Инварианты:
Кроме "полной" 32-бит маски, runtime использует компактные маски в API-запросах.
- `size >= 0x8C`
- `(size - 0x8C) % 68 == 0`
- `attr1 == (size - 0x8C) / 68`
- `attr3 == 68`
Подтверждённый remap `full -> compactMain16` (функции `sub_10013FC0`, `sub_1004BA00`, `sub_1004BB40`):
## 2.4. Type `21`: terrain face (28 байт)
Высокоуровневая структура face:
- флаги face;
- индексы треугольника (`i0, i1, i2`);
- индексы соседей (`n0, n1, n2`, значение `0xFFFF` = нет соседа);
- служебные поля (материал/класс/edge-поля и др.).
Критичные проверки:
- `i0/i1/i2 < vertex_count` (`type=3`);
- `nX == 0xFFFF` или `nX < face_count`.
## 2.5. Маски face и compact-представления
В рантайме используются:
- полная 32-битная маска (`full`);
- компактные представления (`compactMain16`, `compactMaterial6`).
Подтвержденный remap `full -> compactMain16`:
| Full bit | Compact bit |
|---:|---:|
@@ -184,7 +125,7 @@ struct TerrainFace28 {
| `0x00000040` | `0x2000` |
| `0x00200000` | `0x8000` |
Подтверждённый remap `full -> compactMaterial6` (функции `sub_10014090`, `sub_10015540`, `sub_1004BB40`):
Подтвержденный remap `full -> compactMaterial6`:
| Full bit | Compact bit |
|---:|---:|
@@ -195,180 +136,99 @@ struct TerrainFace28 {
| `0x00080000` | `0x10` |
| `0x00000080` | `0x20` |
Подтверждённый remap `compact -> full` (функция `sub_10015680`):
Для 1:1 реализации нужно поддерживать оба представления и обратное восстановление `compact -> full`.
- `a2[4]`/`a2[5]` (compactMain16 required/forbidden) + `a2[6]`/`a2[7]` (compactMaterial6 required/forbidden)
- разворачиваются в `fullRequired/fullForbidden` в `this[4]/this[5]`.
## 2.6. Type `11` и cell-ускоритель terrain
Для toolchain это означает:
`type=11` служит источником cell-ускорителя для terrain-запросов.
- если редактируется только бинарник `type 21`, достаточно сохранять `flags` как есть;
- если реализуется API-совместимый runtime-слой, нужно поддерживать оба представления (`full` и `compact`) и точный remap выше.
Практические требования для editor/toolchain:
### 3.5. Grid-ускоритель terrain-запросов
- не переупорядочивать содержимое без полного пересчета зависимых таблиц;
- сохранять служебные/неизвестные поля побайтно;
- выполнять валидацию диапазонов face/slot после любых правок.
Runtime строит grid descriptor с параметрами:
## 3. Формат `Land.map` (chunk `type=12`)
- origin (`baseX/baseY`);
- масштабные коэффициенты (`invSizeX/invSizeY`);
- размеры сетки (`cellsX`, `cellsY`).
`Land.map``NRes`, содержащий ровно один ресурс `type=12`.
Дальше запросы:
Контракт верхнего уровня:
1. переводят world AABB в диапазон grid-ячеек (`floor(...)`);
2. берут диапазон face через `Res1/Res2` (slot `triStart/triCount`);
3. дополняют кандидаты из cell-списков (chunk type 11);
4. применяют маски флагов;
5. выполняют геометрию (plane/intersection/point-in-triangle).
- `entry.attr1` = `areal_count`;
- payload включает:
- `areal_count` переменных записей ареалов;
- затем grid-секцию cell-попаданий.
### 3.6. Cell-списки по ячейкам (`type 11` и runtime-массивы)
## 3.1. Запись ареала
В `CLandscape` после инициализации используются три параллельных массива по ячейкам (`cellsX * cellsY`):
- `this+31588` (`sub_100164B0` ctor): массив записей по `12` байт, каждая запись содержит динамический буфер `8`-байтовых элементов;
- `this+31592` (`sub_100164E0` ctor): массив записей по `12` байт, каждая запись содержит динамический буфер `4`-байтовых элементов;
- `this+31596` (`sub_1001F880` ctor): массив записей по `12` байт для runtime-объектов/агентов (буфер `4`-байтовых идентификаторов/указателей).
Общий header записи списка:
Старт записи:
```c
struct CellListHdr {
void* ptr; // +0
int count; // +4
int capacity; // +8
};
float anchor_x; // +0
float anchor_y; // +4
float anchor_z; // +8
float reserved_12; // +12
float area_metric; // +16
float normal_x; // +20
float normal_y; // +24
float normal_z; // +28
uint32_t logic_flag; // +32
uint32_t reserved_36; // +36
uint32_t class_id; // +40
uint32_t reserved_44; // +44
uint32_t vertex_count; // +48
uint32_t poly_count; // +52
```
Подтвержденные element-layout:
Далее:
- `this+31588`: элемент `8` байт (`uint32_t id`, `uint32_t aux`), добавление через `sub_10012E20` пишет `aux = 0`;
- `this+31592`: элемент `4` байта (`uint32_t id`);
- `this+31596`: элемент `4` байта (runtime object handle/pointer id).
1. `float3 vertices[vertex_count]`
2. `EdgeLink8 links[vertex_count + 3 * poly_count]`, где
`EdgeLink8 = { int32 area_ref; int32 edge_ref; }`
3. для каждого полигона block:
- `uint32 n`
- `4 * (3*n + 1)` байт данных полигона
Практический вывод для редактора:
## 3.2. Семантика edge-link
- `type 11` должен считаться источником cell-ускорителя;
- неизвестные/дополнительные поля внутри списков должны сохраняться как есть;
- нельзя "нормализовать" или переупорядочивать списки без полного пересчёта всех зависимых runtime-структур.
Для `links[0 .. vertex_count-1]`:
---
- `(-1, -1)` означает «соседа нет»;
- иначе `area_ref` указывает на индекс соседнего ареала, `edge_ref` — на ребро в соседнем ареале.
## 4. Формат `*.map` (ArealMapGeometry, chunk type 12)
## 3.3. Grid-секция после ареалов
### 4.1. Точка входа
`CreateSystemArealMap(..., "<level>.map", ...)` вызывает `sub_1001E0D0`:
1. `niOpenResFile("<level>.map")`;
2. поиск chunk type `12`;
3. чтение chunk-данных;
4. разбор `ArealMapGeometry`.
При ошибках выдаются panic-строки `SystemArealMap panic: ...`.
### 4.2. Верхний уровень chunk 12
Используются:
- `entry.attr1` (из каталога NRes) как `areal_count`;
- `entry[+0x0C]` как размер payload chunk для контроля полного разбора.
Данные chunk:
1. `areal_count` переменных записей ареалов;
2. секция grid-ячеек (`cellsX/cellsY` + списки попаданий).
### 4.3. Переменная запись ареала
Полностью подтверждённые элементы layout:
Формат:
```c
// record = начало записи ареала
float anchor_x = *(float*)(record + 0);
float anchor_y = *(float*)(record + 4);
float anchor_z = *(float*)(record + 8);
float reserved_12 = *(float*)(record + 12); // в retail-данных всегда 0
float area_metric = *(float*)(record + 16); // предрасчитанная площадь ареала
float normal_x = *(float*)(record + 20);
float normal_y = *(float*)(record + 24);
float normal_z = *(float*)(record + 28); // unit vector (|n| ~= 1)
uint32_t logic_flag = *(uint32_t*)(record + 32); // активно используется в runtime
uint32_t reserved_36 = *(uint32_t*)(record + 36); // в retail-данных всегда 0
uint32_t class_id = *(uint32_t*)(record + 40); // runtime-class/type id ареала
uint32_t reserved_44 = *(uint32_t*)(record + 44); // в retail-данных всегда 0
uint32_t vertex_count = *(uint32_t*)(record + 48);
uint32_t poly_count = *(uint32_t*)(record + 52);
float* vertices = (float*)(record + 56); // float3[vertex_count]
// сразу после vertices:
// EdgeLink8[vertex_count + 3*poly_count]
// где EdgeLink8 = { int32_t area_ref; int32_t edge_ref; }
// первые vertex_count записей используются как per-edge соседство границы ареала.
EdgeLink8* links = (EdgeLink8*)(record + 56 + 12 * vertex_count);
uint8_t* p = (uint8_t*)(links + (vertex_count + 3 * poly_count));
for (i=0; i<poly_count; i++) {
uint32_t n = *(uint32_t*)p;
p += 4 * (3*n + 1);
}
// p -> начало следующей записи ареала
```
То есть для toolchain:
- поля `+0/+4/+8`, `+16`, `+20..+28`, `+32`, `+40`, `+48`, `+52` являются runtime-значимыми;
- для `links[0..vertex_count-1]` подтверждена интерпретация как `(area_ref, edge_ref)`:
- `area_ref == -1 && edge_ref == -1` = нет соседа;
- иначе `area_ref` указывает на индекс ареала, `edge_ref` — на индекс ребра в целевом ареале;
- при редактировании безопасно работать через parser+writer этой формулы;
- неизвестные байты внутри записи должны сохраняться без изменений.
Дополнительно по runtime-поведению:
- `anchor_x/anchor_y` валидируются на попадание внутрь полигона; при промахе движок делает случайный re-seed позиции (см. §4.5);
- `logic_flag` по смещению `+32` используется как gating-условие в логике `SystemArealMap`.
### 4.4. Секция grid-ячеек в chunk 12
После массива ареалов идёт:
```c
uint32_t cellsX;
uint32_t cellsY;
for (x in 0..cellsX-1) {
for (y in 0..cellsY-1) {
uint16_t hitCount;
uint16_t areaIds[hitCount];
uint32 cellsX;
uint32 cellsY;
for (x=0; x<cellsX; x++) {
for (y=0; y<cellsY; y++) {
uint16 hitCount;
uint16 areaIds[hitCount];
}
}
```
Runtime упаковывает метаданные ячейки в `uint32`:
В runtime существует упакованное cell-meta представление:
- high 10 bits: `hitCount` (`value >> 22`);
- low 22 bits: `startIndex` (1-based индекс в общем `uint16`-пуле areaIds).
- high 10 бит: `hitCount`;
- low 22 бита: `startIndex` (в общем `areaIds` пуле).
Контроль целостности:
## 3.4. Валидация целостности chunk 12
- после разбора `ptr_end - chunk_begin` должен строго совпасть с `entry[+0x0C]`;
- иначе `SystemArealMap panic: Incorrect ArealMap`.
Обязательные проверки:
### 4.5. Нормализация геометрии при загрузке
- `areal_count > 0`;
- `cellsX > 0 && cellsY > 0`;
- каждый `area_id` из cell-списков `< areal_count`;
- все `area_ref/edge_ref` валидны относительно целевых ареалов;
- полный объем прочитанных байт должен точно совпасть с размером payload.
Если опорная точка ареала не попадает внутрь его полигона:
## 4. `BuildDat.lst`
- до 100 попыток случайного сдвига в радиусе ~30;
- затем до 200 попыток в радиусе ~100.
Это runtime-correction; для 1:1-офлайн инструментов лучше генерировать валидные данные, чтобы не зависеть от недетерминизма `rand()`.
---
## 5. `BuildDat.lst` и объектные категории ареалов
`ArealMap.dll` инициализирует 12 категорий и читает `BuildDat.lst`.
Хардкод-категории (имя -> mask):
Используются 12 объектных категорий ареалов:
| Имя | Маска |
|---|---:|
@@ -385,127 +245,49 @@ Runtime упаковывает метаданные ячейки в `uint32`:
| `Tower_Medium` | `0x80100000` |
| `Tower_Large` | `0x80200000` |
Файл `BuildDat.lst` парсится секционно; при сбое формата используется panic `BuildDat.lst is corrupted`.
Файл должен парситься строго секционно; поврежденный формат считается ошибкой.
---
## 5. Требования к reader/writer/editor
## 6. Требования к toolchain (конвертер/ридер/редактор)
1. Сохранять порядок и бинарную форму chunk'ов, если не выполняется осознанная нормализация.
2. Все неизвестные поля хранить и писать побайтно (`preserve-as-is`).
3. После правок пересчитывать только вычислимые поля, не «чистить» opaque-данные.
4. Проверять диапазоны индексов между связанными таблицами (`nodes/slots/faces/vertices/areas/cells`).
5. Для неизмененных ресурсов обеспечивать byte-identical roundtrip.
### 6.1. Общие принципы 1:1
## 6. Эмпирическая верификация (retail)
1. Никаких "переупорядочиваний по вкусу": сохранять порядок chunk-ов, если не требуется явная нормализация.
2. Все неизвестные поля сохранять побайтно.
3. При roundtrip обеспечивать byte-identical для неизмененных сущностей.
4. Валидации должны повторять runtime-ожидания (размеры, count-формулы, обязательность chunk-ов).
Валидация на `testdata/Parkan - Iron Strategy`:
### 6.2. Для terrain `*.msh`
- карт: `33`
- `Land.msh`: `33/33` валидны
- `Land.map`: `33/33` валидны
- `issues_total = 0`, `errors_total = 0`, `warnings_total = 0`
Обязательные проверки:
Подтвержденные наблюдения:
- наличие chunk types `1,2,3,4,5,11,18,21`;
- type `14` опционален;
- для `type 2`: `size >= 0x8C`, `(size - 0x8C) % 68 == 0`, `attr1 == (size - 0x8C) / 68`;
- `type21_size % 28 == 0`;
- индексы `i0/i1/i2` в `TerrainFace28` не выходят за `vertex_count` (type 3);
- `slot.triStart + slot.triCount` не выходит за `face_count`.
- `Land.msh` порядок chunk'ов стабилен: `[1,2,3,4,5,18,14,11,21]`;
- `Land.map` всегда содержит один chunk `type=12`;
- `cellsX == cellsY == 128` во всех retail-картах;
- `poly_count == 0` во всем проверенном retail-корпусе;
- `normal` имеет длину ~1.0;
- `reserved_12`, `reserved_36`, `reserved_44` в retail наблюдаются как `0`.
Сериализация:
- `flags`, соседи, `edgeClass`, material байты в `TerrainFace28` сохранять как есть;
- содержимое `type 11`-derived cell-списков (`id`, `aux`) сохранять без "починки";
- для packed normal не делать "улучшений" нормализации, если цель 1:1.
### 6.3. Для `*.map` (chunk 12)
Обязательные проверки:
- chunk type `12` существует;
- `areal_count > 0`;
- `cellsX > 0 && cellsY > 0`;
- `|normal_x,normal_y,normal_z| ~= 1` для каждого ареала;
- `links[0..vertex_count-1]` валидны (`-1/-1` или корректные `(area_ref, edge_ref)`);
- полный consumed-bytes строго равен `entry[+0x0C]`.
При редактировании:
- перестраивать только то, что действительно изменено;
- пересчитывать cell-списки и packed `cellMeta` синхронно;
- сохранять неизвестные части записи ареала без изменений.
### 6.4. Рекомендуемая архитектура редактора
1. `Parser`:
- NRes-слой;
- `TerrainMsh`-слой;
- `ArealMapChunk12`-слой.
2. `Model`:
- явные известные поля;
- `raw_unknown` для непросаженных блоков.
3. `Writer`:
- стабильная сериализация;
- проверка контрольных инвариантов перед записью.
4. `Verifier`:
- roundtrip hash/byte-compare;
- runtime-совместимые asserts.
---
## 7. Практический чеклист "движок 1:1"
Для runtime-совместимого движка нужно реализовать:
1. NRes API-уровень (`niOpenResFile`, `niOpenResInMem`, поиск chunk по type, получение data/attrs).
2. `CLandscape` пайплайн загрузки `*.msh` + менеджеров + `CreateSystemArealMap`.
3. Terrain face decode (28-byte запись), mask-фильтр, spatial grid queries.
4. Загрузчик `ArealMapGeometry` (chunk 12) с той же валидацией и packed-cell логикой.
5. Пост-обработку ареалов (пересвязка, корректировки опорных точек).
6. Поддержку `BuildDat.lst` для объектных категорий/схем.
---
## 8. Нерасшифрованные зоны (важно для редакторов)
Ниже поля, которые пока нельзя безопасно "пересобирать по смыслу":
- семантика `class_id` (`record + 40`) на уровне геймдизайна/скриптов (числовое поле подтверждено, но человекочитаемая таблица соответствий не восстановлена полностью);
- ветки формата для `poly_count > 0` (в retail `tmp/gamedata` это всегда `0`, поэтому поведение этих веток подтверждено только по коду, без живых образцов);
- человекочитаемая семантика части битов `TerrainFace28.flags` (при этом remap и бинарные значения подтверждены);
- семантика поля `aux` во `8`-байтовом элементе cell-списка (`this+31588`, второй `uint32_t`), которое в известных runtime-путях инициализируется нулем.
Правило до полного реверса: `preserve-as-is`.
---
## 9. Эмпирическая верификация (retail `tmp/gamedata`)
Для массовой проверки спецификации добавлен валидатор:
Инструмент:
- `tools/terrain_map_doc_validator.py`
Запуск:
## 7. Статус покрытия и что осталось до 100%
```bash
python3 tools/terrain_map_doc_validator.py \
--maps-root tmp/gamedata/DATA/MAPS \
--report-json tmp/terrain_map_doc_validator.report.json
```
Закрыто:
Проверенные инварианты (на 33 картах, 2026-02-12):
- бинарный контракт `Land.msh` и `Land.map`;
- диапазонные и структурные инварианты;
- remap масок `full/compact`;
- валидация на полном retail-корпусе карт.
- `Land.msh`:
- порядок chunk-ов всегда `[1,2,3,4,5,18,14,11,21]`;
- `type11` первые dword всегда `[5767168, 4718593]`;
- `type21` индексы вершин/соседей валидны;
- `type2` slot-таблица валидна по формуле `0x8C + 68*N`.
- `Land.map`:
- всегда один chunk `type 12`;
- `cellsX == cellsY == 128` на всех картах;
- `poly_count == 0` для всех `34662` записей ареалов в retail-наборе;
- `record+12`, `record+36`, `record+44` всегда `0`;
- `area_metric` (`record+16`) стабильно коррелирует с площадью XY-полигона (макс. абсолютное отклонение `51.39`, макс. относительное `14.73%`, `18` кейсов > `5%`);
- `normal` в `record+20..28` всегда unit (диапазон длины `0.9999998758..1.0000001194`);
- link-таблицы `EdgeLink8` проходят строгую валидацию ссылочной целостности.
Осталось до полного 100% архитектурного покрытия движка:
Сводный результат текущего набора данных:
- `issues_total = 0`, `errors_total = 0`, `warnings_total = 0`.
1. Полная доменная семантика `class_id` и `logic_flag` (игровые значения/поведенческие правила).
2. Полная спецификация ветки `poly_count > 0` на живых данных (в retail не встречена).
3. Полная field-level семантика части битов `TerrainFace28.flags` (бинарный контракт и remap закрыты, но не все биты имеют документированные геймплейные имена).

View File

@@ -136,4 +136,18 @@ struct Rect16 {
## 10. Статус валидации
- Инварианты `Texm` реализованы в `tools/msh_doc_validator.py`.
- В текущем окружении нет полного игрового набора текстур в `testdata`, поэтому массовая перепроверка не запускалась.
- На полном retail-корпусе `testdata/Parkan - Iron Strategy` проверено `518/518` текстурных payload (`Texm`) без ошибок.
## 11. Статус покрытия и что осталось до 100%
Закрыто:
1. Заголовок `Texm`, mip-chain layout и `Page` chunk.
2. Базовые decode-пути в RGBA8 для проверок/preview.
3. Корпусная валидация структурных инвариантов.
Осталось:
1. Полная формальная спецификация всех редких служебных комбинаций `flags4/flags5/unk6`.
2. Канонический writer для полного набора форматов (`indexed`, `565`, `556`, `4444`, `88`, `888`, `8888`) с проверенным roundtrip-профилем.
3. Pixel-parity тесты «оригинальный рендер vs новый рендер» с учетом mipSkip/atlas-page веток.

View File

@@ -1,5 +1,33 @@
# UI system
Документ описывает интерфейсную подсистему: ресурсы UI, шрифты, minimap, layout и обработку пользовательского ввода в интерфейсе.
`UI` — подсистема интерфейса:
> Статус: в работе. Спецификация будет дополняться по мере реверс-инжиниринга UI-компонентов движка.
- экранные панели и HUD;
- меню;
- шрифты;
- minimap и служебные оверлеи.
## 1. Архитектурная роль
1. Работает поверх render-пайплайна как отдельный этап кадра.
2. Использует UI-ресурсы из архивных библиотек.
3. Перехватывает пользовательский ввод по правилам фокуса.
## 2. Минимальный runtime-контракт
1. Детерминированный порядок draw-проходов UI.
2. Консистентный фокус и приоритет ввода (UI vs world).
3. Стабильная загрузка font/minimap/ui-ресурсов по именам.
## 3. Статус покрытия и что осталось до 100%
Закрыто:
- позиция UI-слоя в общем кадре и его связи с render/input.
Осталось:
1. Полная спецификация форматов UI layout и контролов.
2. Полный контракт ресурсов шрифтов и text-rendering поведения.
3. Формат minimap-данных и правила трансформации координат.
4. UI parity-тесты (скриншотные и событийные).

View File

@@ -79,4 +79,18 @@ handle = (tableIndex << 16) | wearIndex
## 8. Статус валидации
- Поведение `WEAR` согласовано с текущей спецификацией материалов/текстур и runtime-пайплайном.
- Массовый прогон по полному игровому набору в этом окружении не выполнялся из-за отсутствия корпуса данных в `testdata`.
- Корпусные проверки связки `WEAR -> MAT0 -> Texm` включены в текущий валидаторный контур проекта.
## 9. Статус покрытия и что осталось до 100%
Закрыто:
1. Текстовый формат `WEAR`, включая блок `LIGHTMAPS`.
2. Handle-кодирование material slot и fallback-резолв.
3. Правила совместимого writer/editor path.
Осталось:
1. Полная спецификация edge-case форматов строк (кодировки, редкие разделители, возможные legacy-варианты).
2. Формализация всех ограничений менеджера wear-таблиц в runtime (лимиты и политики вытеснения).
3. Интеграционные parity-тесты на полном цикле «модель -> wear -> material -> texture/lightmap».