aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/specs/ai.md34
-rw-r--r--docs/specs/arealmap.md30
-rw-r--r--docs/specs/behavior.md27
-rw-r--r--docs/specs/control.md27
-rw-r--r--docs/specs/coverage-audit.md51
-rw-r--r--docs/specs/fxid.md18
-rw-r--r--docs/specs/material.md16
-rw-r--r--docs/specs/materials-texm.md10
-rw-r--r--docs/specs/missions.md32
-rw-r--r--docs/specs/msh-animation.md16
-rw-r--r--docs/specs/msh-core.md19
-rw-r--r--docs/specs/msh-notes.md321
-rw-r--r--docs/specs/msh.md19
-rw-r--r--docs/specs/network.md27
-rw-r--r--docs/specs/nres.md777
-rw-r--r--docs/specs/render-parity.md13
-rw-r--r--docs/specs/render.md16
-rw-r--r--docs/specs/rsli.md230
-rw-r--r--docs/specs/runtime-pipeline.md10
-rw-r--r--docs/specs/sound.md31
-rw-r--r--docs/specs/terrain-map-loading.md546
-rw-r--r--docs/specs/texture.md16
-rw-r--r--docs/specs/ui.md32
-rw-r--r--docs/specs/wear.md16
24 files changed, 1032 insertions, 1302 deletions
diff --git a/docs/specs/ai.md b/docs/specs/ai.md
index 545c07b..7570cd0 100644
--- a/docs/specs/ai.md
+++ b/docs/specs/ai.md
@@ -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» для побайтного/пошагового сравнения с оригиналом.
diff --git a/docs/specs/arealmap.md b/docs/specs/arealmap.md
index cac2743..3b234c9 100644
--- a/docs/specs/arealmap.md
+++ b/docs/specs/arealmap.md
@@ -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-тестов поведения навигационных запросов на одинаковых входах.
diff --git a/docs/specs/behavior.md b/docs/specs/behavior.md
index 9ffd2dc..33d403d 100644
--- a/docs/specs/behavior.md
+++ b/docs/specs/behavior.md
@@ -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-сценариях.
diff --git a/docs/specs/control.md b/docs/specs/control.md
index a2d3d44..eb1e535 100644
--- a/docs/specs/control.md
+++ b/docs/specs/control.md
@@ -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-тесты на записанных последовательностях ввода.
diff --git a/docs/specs/coverage-audit.md b/docs/specs/coverage-audit.md
new file mode 100644
index 0000000..638f4c1
--- /dev/null
+++ b/docs/specs/coverage-audit.md
@@ -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-тесты».
diff --git a/docs/specs/fxid.md b/docs/specs/fxid.md
index 22d02d8..f723e17 100644
--- a/docs/specs/fxid.md
+++ b/docs/specs/fxid.md
@@ -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 новый рендер) на фиксированных сценах.
diff --git a/docs/specs/material.md b/docs/specs/material.md
index cd7eea5..12c8296 100644
--- a/docs/specs/material.md
+++ b/docs/specs/material.md
@@ -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 на реальных моделях.
diff --git a/docs/specs/materials-texm.md b/docs/specs/materials-texm.md
index 0397c84..beef3ee 100644
--- a/docs/specs/materials-texm.md
+++ b/docs/specs/materials-texm.md
@@ -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`.
diff --git a/docs/specs/missions.md b/docs/specs/missions.md
index 6f351d0..f531132 100644
--- a/docs/specs/missions.md
+++ b/docs/specs/missions.md
@@ -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-тестов «миссия от старта до завершения».
diff --git a/docs/specs/msh-animation.md b/docs/specs/msh-animation.md
index 8aa2796..ec5a256 100644
--- a/docs/specs/msh-animation.md
+++ b/docs/specs/msh-animation.md
@@ -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» на длинных анимациях.
diff --git a/docs/specs/msh-core.md b/docs/specs/msh-core.md
index 6a33049..60a4453 100644
--- a/docs/specs/msh-core.md
+++ b/docs/specs/msh-core.md
@@ -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-паритетом.
diff --git a/docs/specs/msh-notes.md b/docs/specs/msh-notes.md
index 1bd4808..6e77c4f 100644
--- a/docs/specs/msh-notes.md
+++ b/docs/specs/msh-notes.md
@@ -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)
-Все значения хранятся в **little‑endian** порядке (платформа x86/Win32).
+## 1. Базовые двоичные правила
-## 5.2. Выравнивание
+1. Все форматы в этой подсистеме little-endian.
+2. Внутри NRes данные ресурсов выравниваются по 8 байт.
+3. Внутри payload таблиц padding между записями обычно отсутствует: записи идут подряд по stride.
-- **NRes‑ресурсы:** данные каждого ресурса внутри NRes‑архива выровнены по границе **8 байт** (0‑padding).
-- **Внутренняя структура ресурсов:** таблицы 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
-```
-
-Например:
-
-- `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`
-
-Для 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)
-```
-
-### Нормали (Res4)
-
-```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)),
- )
-```
+## 3.2. Нормали (Res4)
-### UV‑координаты (Res5)
+`int8[4]`, используются первые 3 компоненты:
-```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)
+```text
+n = clamp(s8 / 127.0, -1..1)
```
-### Кодирование нормали (для экспортёра)
+## 3.3. UV (Res5)
-```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 (безопасное значение)
- )
-```
-
-### Кодирование UV (для экспортёра)
-
-```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))))
- )
-```
+`int16[2]`:
-### Строки узлов (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
+```text
+u = s16 / 1024.0
+v = s16 / 1024.0
```
-### Ключ анимации (Res8) и mapping (Res19)
+## 3.4. Animation key (Res8)
-```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)
-```
+`pos(float3) + time(float) + quat(int16x4)`:
-### Эффектный поток (FXID)
-
-```python
-FX_CMD_SIZE = {1:224,2:148,3:200,4:204,5:112,6:4,7:208,8:248,9:208,10:208}
-
-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
+```text
+q = s16 / 32767.0
```
-### 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
+## 4. Практический reader-контракт
-Container/stream формально полностью восстановлен (header, opcode, размеры, инстанцирование). Остаётся необязательная задача: дать «человеко‑читаемые» имена каждому полю внутри payload конкретных opcode.
+Для runtime-совместимого чтения модели:
-## 6.5. Поля `TexmHeader.flags4/flags5/unk6`
+1. Найти нужные ресурсы по `type_id` в NRes.
+2. Проверить `size/stride`-инварианты.
+3. Проверить диапазоны ссылок:
+ - slot -> batch/triangles;
+ - batch -> indices;
+ - indices -> vertices;
+ - anim_map -> anim_keys.
+4. Неизвестные поля и неизвестные ресурсы сохранять через copy-through.
-Бинарный layout и декодер известны, но значения этих трёх полей в контенте используются контекстно; для 1:1 достаточно хранить/восстанавливать их без модификации.
+## 5. Практический writer-контракт
-## 6.6. Что пока не хватает для полноценного обратного экспорта (`OBJ -> MSH/NRes`)
+1. Пересчитывать только явно вычислимые поля.
+2. Не нормализовать opaque-данные без уверенной спецификации.
+3. При roundtrip неизмененных данных требовать byte-identical результат.
+4. Для новых ассетов фиксировать отдельную политику «генерация vs preserve».
-Ниже перечислено то, что нужно закрыть для **lossless round-trip** и 1:1‑поведения при импорте внешней геометрии обратно в формат игры.
+## 6. Runtime-связка материалов и текстур
-### 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`): семантика не разложена, поэтому при экспорте новых ассетов нет детерминированной формулы заполнения.
+1. Модель -> wear-таблица (`*.wea`).
+2. Wear-слот -> material name.
+3. Material -> текущая фаза -> `textureName`.
+4. `Texm` ищется в `Textures.lib` (или lightmap-библиотеке для lightmap-ветки).
-### B) Анимационный path ещё не закрыт как writer
+Fallback:
-1. Нужен полный writer для `Res8/Res19`:
- - точная спецификация байтового формата на запись;
- - правила генерации mapping (`Res19`) по узлам/кадрам;
- - жёсткая фиксация округления как в x87 path (включая edge-case на границах кадра).
-2. Правила биндинга узлов/строк (`Res10`) и `slotFlags` к runtime‑сущностям пока описаны частично и требуют формализации именно для импорта новых данных.
+- материал: `DEFAULT`, затем индекс `0`;
+- текстура/lightmap: fallback-слот движка.
-### C) Материалы, текстуры, эффекты для «полного ассета»
+## 7. Что уже закрыто для 1:1
-1. Для `Texm` не завершён writer, покрывающий все используемые режимы (включая palette path, mip-chain, `Page`, и правила заполнения служебных полей).
-2. Для `FXID` известен контейнер/длины команд, но не завершена field-level семантика payload всех opcode для генерации новых эффектов, эквивалентных оригинальному пайплайну.
-3. Экспорт только `OBJ` покрывает геометрию; для игрового ассета нужен sidecar-слой (материалы/текстуры/эффекты/анимация), иначе импорт неизбежно неполный.
+1. Бинарный контракт базовых MSH таблиц.
+2. Контракт animation sampling (`Res8 + Res19`).
+3. Контракт MAT0/WEAR/Texm на уровне чтения и применения в кадре.
+4. Формат FXID-контейнера, командный поток и fixed command sizes.
+5. Валидация на retail-корпусе через `tools/msh_doc_validator.py` (0 ошибок/предупреждений).
-### D) Что это означает на практике
+## 8. Статус покрытия и что осталось до 100%
-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-метаданных (материал/эффект/анимация).
diff --git a/docs/specs/msh.md b/docs/specs/msh.md
index a4e29b6..0581502 100644
--- a/docs/specs/msh.md
+++ b/docs/specs/msh.md
@@ -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-контрактов.
diff --git a/docs/specs/network.md b/docs/specs/network.md
index 1950e8a..9411c34 100644
--- a/docs/specs/network.md
+++ b/docs/specs/network.md
@@ -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-тестов на контролируемой потере/задержке.
diff --git a/docs/specs/nres.md b/docs/specs/nres.md
index 32ccb1b..03b4c3e 100644
--- a/docs/specs/nres.md
+++ b/docs/specs/nres.md
@@ -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
-```
-
-### 1.2. Заголовок файла (16 байт)
-
-| Смещение | Размер | Тип | Значение | Описание |
-| -------- | ------ | ------- | ------------------- | ------------------------------------ |
-| 0 | 4 | char[4] | `NRes` (0x4E526573) | Магическая сигнатура (little-endian) |
-| 4 | 4 | uint32 | `0x00000100` (256) | Версия формата (1.0) |
-| 8 | 4 | int32 | — | Количество записей в каталоге |
-| 12 | 4 | int32 | — | Полный размер файла в байтах |
-
-**Валидация при открытии:** магическая сигнатура и версия должны совпадать точно. Поле `total_size` (смещение 12) **проверяется на равенство** с фактическим размером файла (`GetFileSize`). Если значения не совпадают — файл отклоняется.
+- чтение;
+- поиск по имени;
+- редактирование (add/replace/remove);
+- полную пересборку архива.
-### 1.3. Положение каталога в файле
+## 2. Общий layout файла
-Каталог располагается в самом конце файла. Его смещение вычисляется по формуле:
-
-```
-directory_offset = total_size - entry_count × 64
+```text
+[Header: 16]
+[Data region: variable, 8-byte aligned chunks]
+[Directory: entry_count * 64, всегда в конце файла]
```
-Данные ресурсов занимают пространство между заголовком (16 байт) и каталогом.
-
-### 1.4. Запись каталога (64 байта)
-
-Каждая запись каталога занимает ровно **64 байта** (0x40):
-
-| Смещение | Размер | Тип | Описание |
-| -------- | ------ | -------- | ------------------------------------------------- |
-| 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 | Индекс сортировки (для двоичного поиска по имени) |
+Критично: каталог всегда расположен в конце файла.
-#### Поле «Имя файла» (смещение 20, 36 байт)
+## 3. Заголовок (16 байт)
-- Максимальная длина имени: **35 символов** + 1 байт null-терминатор.
-- При записи поле сначала обнуляется (`memset(0, 36 байт)`), затем копируется имя (`strncpy`, макс. 35 символов).
-- Поиск по имени выполняется **без учёта регистра** (`_strcmpi`).
+Все значения little-endian.
-#### Поле «Индекс сортировки» (смещение 60)
+| 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` (должен быть равен фактическому размеру файла) |
-Используется для **двоичного поиска по имени**. Содержит индекс оригинальной записи, отсортированной в алфавитном порядке (регистронезависимо). Индекс строится при сохранении файла функцией `sub_10013260` с помощью **пузырьковой сортировки** по именам.
+Производные значения:
-**Алгоритм поиска** (`sub_10011E60`): классический двоичный поиск по отсортированному массиву индексов. Возвращает оригинальный индекс записи или `-1` при отсутствии.
+- `directory_size = entry_count * 64`;
+- `directory_offset = total_size - directory_size`.
-#### Поле «Смещение данных» (смещение 56)
+Ограничения:
-Абсолютное смещение от начала файла. Данные читаются из mapped view: `pointer = mapped_base + data_offset`.
+- `directory_offset >= 16`;
+- `directory_offset + directory_size == total_size`.
-### 1.5. Выравнивание данных
+## 4. Запись каталога (64 байта)
-При добавлении ресурса его данные записываются последовательно, после чего выполняется **выравнивание по 8-байтной границе**:
+| 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` |
-```c
-padding = ((data_size + 7) & ~7) - data_size;
-// Если padding > 0, записываются нулевые байты
-```
-
-Таким образом, каждый блок данных начинается с адреса, кратного 8.
-
-При изменении размера данных ресурса выполняется сдвиг всех последующих данных и обновление смещений всех затронутых записей каталога.
-
-### 1.6. Создание файла (API `niCreateResFile`)
-
-При создании нового файла:
+### 4.1. Имя ресурса (`name_raw`)
-1. Если файл уже существует и содержит корректный NRes-архив, существующий каталог считывается с конца файла, а файл усекается до начала каталога.
-2. Если файл пуст или не является NRes-архивом, создаётся новый с пустым каталогом. Поля `entry_count = 0`, `total_size = 16`.
+Контракт:
-При закрытии файла (`sub_100122D0`):
+- максимум 35 полезных байт + NUL;
+- допускается ровно один терминатор внутри 36-байтового поля;
+- имя сравнивается регистронезависимо по ASCII-правилу (`A..Z` -> `a..z`).
-1. Заголовок переписывается в начало файла (16 байт).
-2. Вычисляется `total_size = data_end_offset + entry_count × 64`.
-3. Индексы сортировки пересчитываются.
-4. Каталог записей записывается в конец файла.
+Для writer/editor:
-### 1.7. Режимы сортировки каталога
+- запрещено писать NUL внутри полезной части имени;
+- запрещены имена длиной > 35 байт.
-Функция `sub_10012560` поддерживает 12 режимов сортировки (0–11):
+### 4.2. Диапазон данных (`data_offset`, `size`)
-| Режим | Порядок сортировки |
-| ----- | --------------------------------- |
-| 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, имя) |
+Для каждой записи:
-### 1.8. Операция `niOpenResFileEx` — флаги открытия
+- `data_offset >= 16`;
+- `data_offset + size <= directory_offset`.
-Второй параметр — битовые флаги:
+Практически (канонический writer): каждый payload начинается с 8-байтного выравнивания.
-| Бит | Маска | Описание |
-| --- | ----- | ----------------------------------------------------------------------------------- |
-| 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, трактовать весь файл как единый ресурс |
-
-### 1.9. Виртуальное касание страниц
-
-Функция `sub_100197D0` выполняет «касание» страниц памяти для принудительной загрузки из memory-mapped файла. Она обходит адресное пространство с шагом 4096 байт (размер страницы), начиная с 0x10000 (64 КБ):
-
-```
-for (result = 0x10000; result < size; result += 4096);
-```
+## 5. Таблица сортировки (`sort_index`)
-Вызывается при чтении данных ресурса с флагом `a3 != 0` для предзагрузки данных в оперативную память.
+`sort_index` задает перестановку «отсортированный список -> исходный индекс записи».
----
+Пусть:
-## Часть 2. Формат RsLi
+- `entries[i]` — i-я запись каталога в исходном порядке;
+- `P` — массив индексов `0..entry_count-1`, отсортированный по `entries[idx].name` (ASCII case-insensitive).
-### 2.1. Общая структура файла
+Тогда в канонической записи:
-```
-┌───────────────────────────────┐ Смещение 0
-│ Заголовок файла (32 байта) │
-├───────────────────────────────┤ Смещение 32
-│ Таблица записей (зашифрована)│
-│ (entry_count × 32 байт) │
-├───────────────────────────────┤ Смещение 32 + entry_count × 32
-│ │
-│ Данные ресурсов │
-│ │
-├───────────────────────────────┤
-│ [Опциональный трейлер — 6 б] │
-└───────────────────────────────┘
-```
-
-### 2.2. Заголовок файла (32 байта)
-
-| Смещение | Размер | Тип | Значение | Описание |
-| -------- | ------ | ------- | ----------------- | --------------------------------------------- |
-| 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)
-
-- Если `*(uint16*)(header + 14) == 0xABBA` — движок **не строит** таблицу индексов в памяти. Значения `entry[i].sort_to_original` используются **как есть** (и для двоичного поиска, и как XOR‑ключ для данных).
-- Если значение **отлично от 0xABBA** — после загрузки выполняется **пузырьковая сортировка** имён и строится перестановка `sort_to_original[]`, которая затем **записывается в `entry[i].sort_to_original`**, перетирая значения из файла. Именно эта перестановка далее используется и для поиска, и как XOR‑ключ (младшие 16 бит).
-
-### 2.3. XOR-шифр таблицы записей
-
-Таблица записей начинается со смещения 32 и зашифрована поточным XOR-шифром. Ключ инициализируется из DWORD по смещению 20 заголовка.
-
-#### Начальное состояние
-
-```
-seed = *(uint32*)(header + 20)
-lo = seed & 0xFF // Младший байт
-hi = (seed >> 8) & 0xFF // Второй байт
-```
-
-#### Алгоритм дешифровки (побайтовый)
-
-Для каждого зашифрованного байта `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
-```
-
-**Пример реализации:**
-
-```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)
-```
+- `entries[i].sort_index = P[i]`.
-Этот же алгоритм используется для шифрования данных ресурсов с методом XOR (флаги 0x20, 0x60, 0xA0), но с другим начальным ключом из записи.
+Это именно таблица для бинарного поиска по имени, а не «ранг текущей записи».
-### 2.4. Запись таблицы (32 байта, на диске, до дешифровки)
+## 6. Поиск по имени
-После дешифровки каждая запись имеет следующую структуру:
+Алгоритм поиска:
-| Смещение | Размер | Тип | Описание |
-| -------- | ------ | -------- | -------------------------------------------------------------- |
-| 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 (буфер декомпрессии может быть больше)
-```
-
-#### Методы сжатия (биты 8–5, маска 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
-```
+1. Выполнить бинарный поиск по диапазону `i in [0, entry_count)`.
+2. На шаге `i` взять `target = entries[i].sort_index`.
+3. Сравнить искомое имя с `entries[target].name` (ASCII case-insensitive).
+4. При совпадении вернуть `target`.
-#### Дешифровка (псевдокод)
-
-```
-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) // Диапазон: 0–4095
-length = (high & 0x0F) + 3 // Диапазон: 3–18
-```
-
-### 3.3. LZSS с адаптивным кодированием Хаффмана (метод 0x80)
-
-Расширенный вариант LZSS, где литералы и длины совпадений кодируются с помощью адаптивного дерева Хаффмана.
-
-#### Параметры
-
-| Параметр | Значение |
-| -------------------------------- | ------------------------------ |
-| Размер кольцевого буфера | 4096 байт |
-| Начальная позиция записи | **4036** (0xFC4) |
-| Начальное заполнение | 0x20 (пробел) |
-| Количество листовых узлов дерева | 314 |
-| Символы литералов | 0–255 (байты) |
-| Символы длин | 256–313 (длина = символ − 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 кодов
- - 0–143: 8-битные коды
- - 144–255: 9-битные коды
- - 256–279: 7-битные коды
- - 280–287: 8-битные коды
-- Дистанции: 30 кодов, все 5-битные
-
-Используются предопределённые таблицы длин и дистанций (`unk_100370AC`, `unk_1003712C` и соответствующие экстра-биты).
-
-#### Блок типа 2 (динамические коды)
-
-1. Прочитать 5 бит → `HLIT` (количество литералов/длин − 257). Диапазон: 257–286.
-2. Прочитать 5 бит → `HDIST` (количество дистанций − 1). Диапазон: 1–30.
-3. Прочитать 4 бита → `HCLEN` (количество кодов длин − 4). Диапазон: 4–19.
-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: дополнительные поля
-};
-```
+Fail-safe поведение:
----
+- если `sort_index` некорректен (выход за диапазон), реализация должна перейти на линейный fallback по всем записям;
+- fallback использует то же ASCII case-insensitive сравнение.
-## Часть 5. Экспортируемые API-функции
+## 7. Каноническая пересборка архива
-### 5.1. NRes API
+Канонический writer выполняет:
-| Функция | Описание |
-| ------------------------------ | ------------------------------------------------------------------------- |
-| `niOpenResFile(path)` | Открыть NRes-архив (только чтение), эквивалент `niOpenResFileEx(path, 0)` |
-| `niOpenResFileEx(path, flags)` | Открыть NRes-архив с флагами |
-| `niOpenResInMem(ptr, size)` | Открыть NRes-архив из памяти |
-| `niCreateResFile(path)` | Создать/открыть NRes-архив для записи |
+1. Пишет заглушку заголовка (16 байт).
+2. Пишет payload всех записей в текущем порядке.
+3. После каждого payload добавляет 0-padding до кратности 8.
+4. Пересчитывает `sort_index` через сортировку имен.
+5. Дописывает каталог (`entry_count * 64`).
+6. Пересчитывает и записывает `total_size`.
-### 5.2. RsLi API
+Итоговый файл должен удовлетворять всем ограничениям из разделов 3–5.
-| Функция | Описание |
-| ------------------------------- | -------------------------------------------------------- |
-| `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)` | Получить размер выделенного блока |
+## 8. Режим `raw` (совместимость инструментов)
----
+Для служебных инструментов допускается `raw_mode`:
-## Часть 6. Контрольные заметки для реализации
+- любой бинарный файл трактуется как один «сырой» ресурс;
+- возвращается одна запись (`name = RAW`, `data_offset = 0`, `size = len(file)`).
-### 6.1. Кодировки и регистр
+Этот режим не является форматом `NRes` на диске, это только режим открытия.
-- **NRes**: имена хранятся **как есть** (case-insensitive при поиске через `_strcmpi`).
-- **RsLi**: имена хранятся в **верхнем регистре**. Перед поиском запрос приводится к верхнему регистру (`_strupr`). Сравнение — через `strcmp` (case-sensitive для уже uppercase строк).
+## 9. Контрольные инварианты
-### 6.2. Порядок байт
+Минимальный набор проверок при чтении:
-Все значения хранятся в **little-endian** порядке (платформа x86/Win32).
+1. `magic == "NRes"`.
+2. `version == 0x100`.
+3. `entry_count >= 0`.
+4. `header.total_size == file_size`.
+5. Каталог находится в конце файла.
+6. Для каждой записи диапазон данных не пересекает каталог.
+7. Имя корректно C-терминировано и не длиннее 35 байт.
-### 6.3. Выравнивание
+Минимальный набор проверок при записи:
-- **NRes**: данные каждого ресурса выровнены по границе **8 байт** (0-padding между файлами).
-- **RsLi**: выравнивание данных не описано в коде (данные идут подряд).
+1. Все имена <= 35 байт и без внутренних NUL.
+2. `sort_index` формирует валидную перестановку `0..N-1`.
+3. Все паддинги между payload состоят из нулевых байт.
+4. `total_size` равен фактической длине выходного файла.
-### 6.4. Размер записей на диске
+## 10. Эмпирическая проверка на retail-корпусе
-- **NRes**: каталог — **64 байта** на запись, расположен в конце файла.
-- **RsLi**: таблица — **32 байта** на запись (зашифрованная), расположена в начале файла (сразу после 32-байтного заголовка).
+Валидация на полном наборе `testdata/Parkan - Iron Strategy`:
-### 6.5. Кэширование и memory mapping
+- найдено `120` архивов `NRes`;
+- roundtrip `unpack -> repack -> byte-compare`: `120/120` совпали побайтно;
+- критических расхождений формата не обнаружено.
-Оба формата используют Windows Memory-Mapped Files (`CreateFileMapping` + `MapViewOfFile`). NRes-архивы организованы в глобальный **связный список** (`dword_1003A66C`) со счётчиком ссылок и таймером неактивности (10 секунд = 0x2710 мс). При refcount == 0 и истечении таймера архив автоматически выгружается (если не установлен флаг `is_cacheable`).
+Инструмент:
-### 6.6. Размер seed XOR
+- `tools/archive_roundtrip_validator.py`
-- **Заголовок 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 байта.
+## 11. Статус покрытия и что осталось до 100%
-### 6.7. Эмпирическая проверка на данных игры
+Закрыто:
-- Найдено архивов по сигнатуре: **122** (`NRes`: 120, `RsLi`: 2).
-- Выполнен полный roundtrip `unpack -> pack -> byte-compare`: **122/122** архивов совпали побайтно.
-- Для `RsLi` в проверенном наборе встретились методы: `0x040` и `0x100`.
+- формат заголовка/каталога;
+- правила поиска;
+- каноническая пересборка;
+- строгие инварианты валидатора;
+- побайтовый roundtrip на retail-корпусе.
-Подтверждённые нюансы:
+Осталось до полного 100% архитектурного покрытия движка:
-- Для 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-уровневые гарантии замены файла зависят от платформы и файловой системы).
diff --git a/docs/specs/render-parity.md b/docs/specs/render-parity.md
index 5c63c13..8955414 100644
--- a/docs/specs/render-parity.md
+++ b/docs/specs/render-parity.md
@@ -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.
diff --git a/docs/specs/render.md b/docs/specs/render.md
index ea63197..06feaef 100644
--- a/docs/specs/render.md
+++ b/docs/specs/render.md
@@ -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-комбинации).
diff --git a/docs/specs/rsli.md b/docs/specs/rsli.md
new file mode 100644
index 0000000..298cf2a
--- /dev/null
+++ b/docs/specs/rsli.md
@@ -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-диапазон).
diff --git a/docs/specs/runtime-pipeline.md b/docs/specs/runtime-pipeline.md
index 329afc1..fb8af06 100644
--- a/docs/specs/runtime-pipeline.md
+++ b/docs/specs/runtime-pipeline.md
@@ -6,3 +6,13 @@
Эта страница оставлена как совместимый указатель для старых ссылок.
+## Статус покрытия и что осталось до 100%
+
+Закрыто:
+
+1. Актуальный runtime-пайплайн централизован в `render.md`.
+
+Осталось:
+
+1. Поддерживать обратную совместимость ссылок при дальнейшей декомпозиции render-документа.
+
diff --git a/docs/specs/sound.md b/docs/specs/sound.md
index da2a6ee..360f590 100644
--- a/docs/specs/sound.md
+++ b/docs/specs/sound.md
@@ -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-тестов (тайминг/громкость/панорама).
diff --git a/docs/specs/terrain-map-loading.md b/docs/specs/terrain-map-loading.md
index 34f6249..62c1e0a 100644
--- a/docs/specs/terrain-map-loading.md
+++ b/docs/specs/terrain-map-loading.md
@@ -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()`:
+```text
+[1, 2, 3, 4, 5, 18, 14, 11, 21]
+```
-| Порядок | 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) |
+## 2.2. Stride и атрибуты
-Ключевые проверки:
+| 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 |
-- отсутствие type `18` вызывает `Unable to find microtexture mapping chunk`;
-- отсутствие остальных обязательных чанков вызывает `Unable to open file`.
+Общее правило для этих chunk'ов:
-### 3.2. Node/slot структура для terrain
+- `attr1 == size / stride`
+- `attr3 == stride`
-Terrain-код использует те же stride и адресацию, что и core-описание:
+## 2.3. Type `2`: slot table
-- node-запись: `38` байт;
-- slot-запись: `68` байт;
-- доступ к первому slot-index: `node + 8`;
-- tri-диапазон в slot: `slot + 140` (offset 0 внутри slot), `slot + 142` (offset 2).
+`type=2` содержит:
-Это согласуется с [MSH core](msh-core.md) для `Res1/Res2`:
+- заголовок `0x8C` байт;
+- затем таблицу slots по `68` байт.
-- `Res1`: `uint16[19]` на node;
-- `Res2`: header + slot table (`0x8C + N * 0x44`).
+Инварианты:
-### 3.3. Terrain face record (type 21, 28 bytes)
+- `size >= 0x8C`
+- `(size - 0x8C) % 68 == 0`
+- `attr1 == (size - 0x8C) / 68`
+- `attr3 == 68`
-Подтвержденные поля из runtime-декодирования face:
+## 2.4. Type `21`: terrain face (28 байт)
-```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
-};
-```
+Высокоуровневая структура face:
-`edgeClass` декодируется как:
+- флаги face;
+- индексы треугольника (`i0, i1, i2`);
+- индексы соседей (`n0, n1, n2`, значение `0xFFFF` = нет соседа);
+- служебные поля (материал/класс/edge-поля и др.).
-- `edge0 = byte26 & 0x3`
-- `edge1 = (byte26 >> 2) & 0x3`
-- `edge2 = (byte26 >> 4) & 0x3`
+Критичные проверки:
-### 3.4. Маски флагов face
+- `i0/i1/i2 < vertex_count` (`type=3`);
+- `nX == 0xFFFF` или `nX < face_count`.
-Во многих запросах применяется фильтр:
+## 2.5. Маски face и compact-представления
-```c
-(faceFlags & requiredMask) == requiredMask &&
-(faceFlags | ~forbiddenMask) == ~forbiddenMask
-```
+В рантайме используются:
-Эквивалентно: "все required-биты выставлены, forbidden-биты отсутствуют".
+- полная 32-битная маска (`full`);
+- компактные представления (`compactMain16`, `compactMaterial6`).
-Подтверждено активное использование битов:
-
-- `0x8` (особая обработка в трассировке)
-- `0x2000`
-- `0x20000`
-- `0x100000`
-- `0x200000`
-
-Кроме "полной" 32-бит маски, runtime использует компактные маски в API-запросах.
-
-Подтверждённый remap `full -> compactMain16` (функции `sub_10013FC0`, `sub_1004BA00`, `sub_1004BB40`):
+Подтвержденный 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`):
-
-- `a2[4]`/`a2[5]` (compactMain16 required/forbidden) + `a2[6]`/`a2[7]` (compactMaterial6 required/forbidden)
-- разворачиваются в `fullRequired/fullForbidden` в `this[4]/this[5]`.
+Для 1:1 реализации нужно поддерживать оба представления и обратное восстановление `compact -> full`.
-Для toolchain это означает:
+## 2.6. Type `11` и cell-ускоритель terrain
-- если редактируется только бинарник `type 21`, достаточно сохранять `flags` как есть;
-- если реализуется API-совместимый runtime-слой, нужно поддерживать оба представления (`full` и `compact`) и точный remap выше.
+`type=11` служит источником cell-ускорителя для terrain-запросов.
-### 3.5. Grid-ускоритель terrain-запросов
+Практические требования для editor/toolchain:
-Runtime строит grid descriptor с параметрами:
+- не переупорядочивать содержимое без полного пересчета зависимых таблиц;
+- сохранять служебные/неизвестные поля побайтно;
+- выполнять валидацию диапазонов face/slot после любых правок.
-- origin (`baseX/baseY`);
-- масштабные коэффициенты (`invSizeX/invSizeY`);
-- размеры сетки (`cellsX`, `cellsY`).
+## 3. Формат `Land.map` (chunk `type=12`)
-Дальше запросы:
+`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).
+Контракт верхнего уровня:
-### 3.6. Cell-списки по ячейкам (`type 11` и runtime-массивы)
+- `entry.attr1` = `areal_count`;
+- payload включает:
+ - `areal_count` переменных записей ареалов;
+ - затем grid-секцию cell-попаданий.
-В `CLandscape` после инициализации используются три параллельных массива по ячейкам (`cellsX * cellsY`):
+## 3.1. Запись ареала
-- `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).
-
-Практический вывод для редактора:
-
-- `type 11` должен считаться источником cell-ускорителя;
-- неизвестные/дополнительные поля внутри списков должны сохраняться как есть;
-- нельзя "нормализовать" или переупорядочивать списки без полного пересчёта всех зависимых runtime-структур.
-
----
-
-## 4. Формат `*.map` (ArealMapGeometry, chunk type 12)
-
-### 4.1. Точка входа
-
-`CreateSystemArealMap(..., "<level>.map", ...)` вызывает `sub_1001E0D0`:
-
-1. `niOpenResFile("<level>.map")`;
-2. поиск chunk type `12`;
-3. чтение chunk-данных;
-4. разбор `ArealMapGeometry`.
+Далее:
-При ошибках выдаются panic-строки `SystemArealMap panic: ...`.
+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)` байт данных полигона
-### 4.2. Верхний уровень chunk 12
+## 3.2. Семантика edge-link
-Используются:
+Для `links[0 .. vertex_count-1]`:
-- `entry.attr1` (из каталога NRes) как `areal_count`;
-- `entry[+0x0C]` как размер payload chunk для контроля полного разбора.
+- `(-1, -1)` означает «соседа нет»;
+- иначе `area_ref` указывает на индекс соседнего ареала, `edge_ref` — на ребро в соседнем ареале.
-Данные chunk:
+## 3.3. Grid-секция после ареалов
-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`:
-
-- high 10 bits: `hitCount` (`value >> 22`);
-- low 22 bits: `startIndex` (1-based индекс в общем `uint16`-пуле areaIds).
-
-Контроль целостности:
+В runtime существует упакованное cell-meta представление:
-- после разбора `ptr_end - chunk_begin` должен строго совпасть с `entry[+0x0C]`;
-- иначе `SystemArealMap panic: Incorrect ArealMap`.
+- high 10 бит: `hitCount`;
+- low 22 бита: `startIndex` (в общем `areaIds` пуле).
-### 4.5. Нормализация геометрии при загрузке
+## 3.4. Валидация целостности chunk 12
-Если опорная точка ареала не попадает внутрь его полигона:
-
-- до 100 попыток случайного сдвига в радиусе ~30;
-- затем до 200 попыток в радиусе ~100.
-
-Это runtime-correction; для 1:1-офлайн инструментов лучше генерировать валидные данные, чтобы не зависеть от недетерминизма `rand()`.
-
----
+Обязательные проверки:
-## 5. `BuildDat.lst` и объектные категории ареалов
+- `areal_count > 0`;
+- `cellsX > 0 && cellsY > 0`;
+- каждый `area_id` из cell-списков `< areal_count`;
+- все `area_ref/edge_ref` валидны относительно целевых ареалов;
+- полный объем прочитанных байт должен точно совпасть с размером payload.
-`ArealMap.dll` инициализирует 12 категорий и читает `BuildDat.lst`.
+## 4. `BuildDat.lst`
-Хардкод-категории (имя -> mask):
+Используются 12 объектных категорий ареалов:
| Имя | Маска |
|---|---:|
@@ -385,127 +245,49 @@ Runtime упаковывает метаданные ячейки в `uint32`:
| `Tower_Medium` | `0x80100000` |
| `Tower_Large` | `0x80200000` |
-Файл `BuildDat.lst` парсится секционно; при сбое формата используется panic `BuildDat.lst is corrupted`.
-
----
-
-## 6. Требования к toolchain (конвертер/ридер/редактор)
-
-### 6.1. Общие принципы 1:1
-
-1. Никаких "переупорядочиваний по вкусу": сохранять порядок chunk-ов, если не требуется явная нормализация.
-2. Все неизвестные поля сохранять побайтно.
-3. При roundtrip обеспечивать byte-identical для неизмененных сущностей.
-4. Валидации должны повторять runtime-ожидания (размеры, count-формулы, обязательность chunk-ов).
-
-### 6.2. Для terrain `*.msh`
-
-Обязательные проверки:
-
-- наличие 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`.
-
-Сериализация:
-
-- `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"
+## 5. Требования к reader/writer/editor
-Для runtime-совместимого движка нужно реализовать:
+1. Сохранять порядок и бинарную форму chunk'ов, если не выполняется осознанная нормализация.
+2. Все неизвестные поля хранить и писать побайтно (`preserve-as-is`).
+3. После правок пересчитывать только вычислимые поля, не «чистить» opaque-данные.
+4. Проверять диапазоны индексов между связанными таблицами (`nodes/slots/faces/vertices/areas/cells`).
+5. Для неизмененных ресурсов обеспечивать byte-identical roundtrip.
-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` для объектных категорий/схем.
+## 6. Эмпирическая верификация (retail)
----
+Валидация на `testdata/Parkan - Iron Strategy`:
-## 8. Нерасшифрованные зоны (важно для редакторов)
+- карт: `33`
+- `Land.msh`: `33/33` валидны
+- `Land.map`: `33/33` валидны
+- `issues_total = 0`, `errors_total = 0`, `warnings_total = 0`
-Ниже поля, которые пока нельзя безопасно "пересобирать по смыслу":
+Подтвержденные наблюдения:
-- семантика `class_id` (`record + 40`) на уровне геймдизайна/скриптов (числовое поле подтверждено, но человекочитаемая таблица соответствий не восстановлена полностью);
-- ветки формата для `poly_count > 0` (в retail `tmp/gamedata` это всегда `0`, поэтому поведение этих веток подтверждено только по коду, без живых образцов);
-- человекочитаемая семантика части битов `TerrainFace28.flags` (при этом remap и бинарные значения подтверждены);
-- семантика поля `aux` во `8`-байтовом элементе cell-списка (`this+31588`, второй `uint32_t`), которое в известных runtime-путях инициализируется нулем.
+- `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`.
-Правило до полного реверса: `preserve-as-is`.
+Инструмент:
----
-
-## 9. Эмпирическая верификация (retail `tmp/gamedata`)
+- `tools/terrain_map_doc_validator.py`
-Для массовой проверки спецификации добавлен валидатор:
+## 7. Статус покрытия и что осталось до 100%
-- `tools/terrain_map_doc_validator.py`
+Закрыто:
-Запуск:
+- бинарный контракт `Land.msh` и `Land.map`;
+- диапазонные и структурные инварианты;
+- remap масок `full/compact`;
+- валидация на полном retail-корпусе карт.
-```bash
-python3 tools/terrain_map_doc_validator.py \
- --maps-root tmp/gamedata/DATA/MAPS \
- --report-json tmp/terrain_map_doc_validator.report.json
-```
+Осталось до полного 100% архитектурного покрытия движка:
-Проверенные инварианты (на 33 картах, 2026-02-12):
-
-- `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` проходят строгую валидацию ссылочной целостности.
-
-Сводный результат текущего набора данных:
-
-- `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 закрыты, но не все биты имеют документированные геймплейные имена).
diff --git a/docs/specs/texture.md b/docs/specs/texture.md
index c25ec56..b43ab1a 100644
--- a/docs/specs/texture.md
+++ b/docs/specs/texture.md
@@ -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 веток.
diff --git a/docs/specs/ui.md b/docs/specs/ui.md
index 9d71dfd..bb915cb 100644
--- a/docs/specs/ui.md
+++ b/docs/specs/ui.md
@@ -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-тесты (скриншотные и событийные).
diff --git a/docs/specs/wear.md b/docs/specs/wear.md
index 61c799d..e969f9c 100644
--- a/docs/specs/wear.md
+++ b/docs/specs/wear.md
@@ -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».