aboutsummaryrefslogtreecommitdiff
path: root/docs/specs/materials-texm.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/specs/materials-texm.md')
-rw-r--r--docs/specs/materials-texm.md878
1 files changed, 6 insertions, 872 deletions
diff --git a/docs/specs/materials-texm.md b/docs/specs/materials-texm.md
index baa80ae..0397c84 100644
--- a/docs/specs/materials-texm.md
+++ b/docs/specs/materials-texm.md
@@ -1,874 +1,8 @@
-# Materials, WEAR, MAT0 и Texm
+# Materials, WEAR, Texm
-Документ описывает материальную подсистему движка (World3D/Ngi32) на уровне, достаточном для:
+Старая объединённая страница разбита по объектам.
-- реализации runtime 1:1;
-- создания инструментов чтения/валидации;
-- создания инструментов конвертации и редактирования с lossless round-trip.
-
-Источник: дизассемблированные `tmp/disassembler1/*.c` и `tmp/disassembler2/*.asm`, плюс проверка на `tmp/gamedata`.
-
----
-
-## 1. Идентификаторы и сущности
-
-| Сущность | ID (LE uint32) | ASCII | Где используется |
-|---|---:|---|---|
-| Material resource | `0x3054414D` | `MAT0` | `Material.lib` |
-| Wear resource | `0x52414557` | `WEAR` | `.wea` записи в world/mission `.rlb` |
-| Texture resource | `0x6D786554` | `Texm` | `Textures.lib`, `lightmap.lib`, другие `.lib/.rlb` |
-| Atlas tail chunk | `0x65676150` | `Page` | хвост payload `Texm` |
-
-Дополнительно: палитры загружаются отдельным путём (через `SetPalettesLib` + `sub_10002B40`) и не являются `Texm`.
-
----
-
-## 2. Архитектура подсистемы
-
-### 2.1 Экспортируемые точки входа (World3D)
-
-- `LoadMatManager`
-- `SetPalettesLib`
-- `SetTexturesLib`
-- `SetMaterialLib`
-- `SetLightMapLib`
-- `SetGameTime`
-- `UnloadAllTextures`
-
-`Set*Lib` просто копируют строки путей в глобальные буферы; валидации пути нет.
-
-### 2.2 Дефолтные библиотеки (из `iron3d.dll`)
-
-- `Textures.lib`
-- `Material.lib`
-- `LightMap.lib`
-- `palettes.lib` (строка собирается как `'p' + "alettes.lib"`)
-
-### 2.3 Ключевые runtime-хранилища
-
-1. Менеджер материалов (`LoadMatManager`) — объект `0x470` байт.
-2. Кэш текстурных объектов.
-3. Кэш lightmap-объектов.
-4. Банк загруженных палитр.
-5. Глобальный пул определений материалов (`MAT0`).
-
----
-
-## 3. Layout `MatManager` (0x470)
-
-Объект содержит 70 таблиц wear/lightmaps (не 140).
-
-```c
-// int-индексы относительно this (DWORD*), размер 284 DWORD = 0x470
-// [0] vtable
-// [1] callback iface
-// [2] callback data
-// [3..72] wearTablePtrs[70] // ptr на массив по 8 байт
-// [73..142] wearCounts[70]
-// [143] tableCount
-// [144..213] lightmapTablePtrs[70] // ptr на массив по 4 байта
-// [214..283] lightmapCounts[70]
-```
-
-### 3.1 Vtable методов (`off_100209E4`)
-
-| Индекс | Функция | Назначение |
-|---:|---|---|
-| 0 | `loc_10002CE0` | служебный/RTTI-заглушка |
-| 1 | `sub_10002D10` | деструктор + освобождение таблиц |
-| 2 | `PreLoadAllTextures` | экспорт, но фактически `retn 4` (заглушка) |
-| 3 | `sub_100031F0` | получить материал-фазу по `gameTime` |
-| 4 | `sub_10003AE0` | сбросить startTime записи wear к `SetGameTime()` |
-| 5 | `sub_10003680` | получить материал-фазу по нормализованному `t` |
-| 6 | `sub_10003B10` | загрузить wear/lightmaps (файл/ресурс) |
-| 7 | `sub_10003F80` | загрузить wear/lightmaps из буфера |
-| 8 | `sub_100031A0` | получить указатель на lightmap texture object |
-| 9 | `sub_10003AB0` | получить runtime-метаданные материала |
-| 10 | `sub_100031D0` | получить `wearCount` для таблицы |
-
-### 3.2 Кодирование material-handle
-
-`uint32 handle = (tableIndex << 16) | wearIndex`.
-
-- `HIWORD(handle)` -> индекс таблицы `0..69`
-- `LOWORD(handle)` -> индекс материала в wear-таблице
-
----
-
-## 4. Глобальные кэши и их ёмкость
-
-Ёмкости подтверждены границами циклов/адресов в дизассемблере.
-
-### 4.1 Кэш текстур (`dword_1014E910`...)
-
-- Размер слота: `5 DWORD` (20 байт)
-- Ёмкость: `777`
-
-```c
-struct TextureSlot {
- int32_t resIndex; // +0 индекс записи в NRes (не hash), -1 = свободно
- void* textureObject; // +4
- int32_t refCount; // +8
- uint32_t lastZeroRefTime;// +12 время, когда refCount стал 0
- uint32_t loadFlags; // +16 флаги загрузки
-};
-```
-
-`lastZeroRefTime` реально используется: texture-слоты с `refCount==0` освобождаются отложенно периодическим GC.
-
-### 4.2 Кэш lightmaps (`dword_10029C98`...)
-
-- Тот же layout `5 DWORD`
-- Ёмкость: `100`
-
-Для lightmap-слотов аналогичного периодического GC по `lastZeroRefTime` в `World3D` не наблюдается.
-
-### 4.3 Пул материалов (`dword_100669F0`...)
-
-- Шаг: `92 DWORD` (`368` байт)
-- Ёмкость: `700`
-
-Фиксированные поля на шаг `i*92`:
-
-| DWORD offset | Byte offset | Поле |
-|---:|---:|---|
-| 0 | 0 | `nameResIndex` (`MAT0` entry index), `-1` = free |
-| 1 | 4 | `refCount` |
-| 2 | 8 | `phaseCount` |
-| 3 | 12 | `phaseArrayPtr` (`phaseCount * 76`) |
-| 4 | 16 | `animBlockCount` (`< 20`) |
-| 5..84 | 20..339 | `animBlocks[20]` по 16 байт |
-| 85 | 340 | metaA (`dword_10066B44`) |
-| 86 | 344 | metaB (`dword_10066B48`) |
-| 87 | 348 | metaC (`dword_10066B4C`) |
-| 88 | 352 | metaD (`dword_10066B50`) |
-| 89 | 356 | flagA (`dword_10066B54`) |
-| 90 | 360 | nibbleMode (`dword_10066B58`) |
-| 91 | 364 | flagB (`dword_10066B5C`) |
-
-### 4.4 Банк палитр
-
-- `dword_1013DA58[]`
-- Загружается до `286` элементов (26 букв * 11 вариантов)
-
----
-
-## 5. Загрузка палитр (`sub_10002B40`)
-
-### 5.1 Генерация имён
-
-Движок перебирает:
-
-- буквы `'A'..'Z'`
-- суффиксы: `""`, `"0"`, `"1"`, ..., `"9"`
-
-И формирует имя:
-
-- `<Letter><Suffix>.PAL`
-- примеры: `A.PAL`, `A0.PAL`, ..., `Z9.PAL`
-
-### 5.2 Индекс палитры
-
-`paletteIndex = letterIndex * 11 + variantIndex`
-
-- `letterIndex = 0..25`
-- `variantIndex = 0..10` (`""`=0, `"0"`=1, ..., `"9"`=10)
-
-### 5.3 Поведение
-
-- Если запись не найдена: `paletteSlots[idx] = 0`
-- Если найдена: payload отдаётся в рендер (`render->method+60`)
-
----
-
-## 6. Формат `MAT0` (`Material.lib`)
-
-### 6.1 Атрибуты NRes entry
-
-`sub_10004310` использует:
-
-- `entry.type` = `MAT0`
-- `entry.attr1` (bitfield runtime-флагов)
-- `entry.attr2` (версия/вариант заголовка payload)
-- `entry.attr3` не используется в runtime-парсере
-
-Маппинг `attr1`:
-
-- bit0 (`0x01`) -> добавить флаг `0x200000` в загрузку текстур фазы
-- bit1 (`0x02`) -> `flagA=1`; при некоторых HW-условиях дополнительно OR `0x80000`
-- bits2..5 -> `nibbleMode = (attr1 >> 2) & 0xF`
-- bit6 (`0x40`) -> `flagB=1`
-
-### 6.2 Payload layout
-
-```c
-struct Mat0Payload {
- uint16_t phaseCount;
- uint16_t animBlockCount; // должно быть < 20, иначе "Too many animations for material."
-
- // Если attr2 >= 2:
- uint8_t metaA8;
- uint8_t metaB8;
- // Если attr2 >= 3:
- uint32_t metaC32;
- // Если attr2 >= 4:
- uint32_t metaD32;
-
- PhaseRecordByte34 phases[phaseCount];
- AnimBlockRaw anim[animBlockCount];
-};
-```
-
-Если `attr2 < 2`, runtime-значения по умолчанию:
-
-- `metaA = 255`
-- `metaB = 255`
-- `metaC = 1.0f` (`0x3F800000`)
-- `metaD = 0`
-
-### 6.3 `PhaseRecordByte34` -> runtime `76 bytes`
-
-Сырые 34 байта:
-
-```c
-struct PhaseRecordByte34 {
- uint8_t p[18]; // параметры
- char textureName[16];// если textureName[0]==0, текстуры нет
-};
-```
-
-Преобразование в runtime-структуру (точный порядок):
-
-| Из `p[i]` | В offset runtime | Преобразование |
-|---:|---:|---|
-| `p[0]` | `+16` | `p[0] / 255.0f` |
-| `p[1]` | `+20` | `p[1] / 255.0f` |
-| `p[2]` | `+24` | `p[2] / 255.0f` |
-| `p[3]` | `+28` | `p[3] * 0.01f` |
-| `p[4]` | `+0` | `p[4] / 255.0f` |
-| `p[5]` | `+4` | `p[5] / 255.0f` |
-| `p[6]` | `+8` | `p[6] / 255.0f` |
-| `p[7]` | `+12` | `p[7] / 255.0f` |
-| `p[8]` | `+32` | `p[8] / 255.0f` |
-| `p[9]` | `+36` | `p[9] / 255.0f` |
-| `p[10]` | `+40` | `p[10] / 255.0f` |
-| `p[11]` | `+44` | `p[11] / 255.0f` |
-| `p[12]` | `+48` | `p[12] / 255.0f` |
-| `p[13]` | `+52` | `p[13] / 255.0f` |
-| `p[14]` | `+56` | `p[14] / 255.0f` |
-| `p[15]` | `+60` | `p[15] / 255.0f` |
-| `p[16]` | `+64` | `uint32 = p[16]` |
-| `p[17]` | `+72` | `int32 = p[17]` |
-
-Текстура:
-
-- `textureName[0] == 0` -> `runtime[+68] = -1` и `runtime[+72] = -1`
-- иначе `runtime[+68] = LoadTexture(textureName, flags)`
-
-### 6.4 Runtime-запись фазы (76 байт)
-
-```c
-struct MaterialPhase76 {
- float f0; // +0
- float f1; // +4
- float f2; // +8
- float f3; // +12
- float f4; // +16
- float f5; // +20
- float f6; // +24
- float f7; // +28
- float f8; // +32
- float f9; // +36
- float f10; // +40
- float f11; // +44
- float f12; // +48
- float f13; // +52
- float f14; // +56
- float f15; // +60
- uint32_t u16; // +64
- int32_t texSlot; // +68 (индекс в texture cache, либо -1)
- int32_t i18; // +72
-};
-```
-
-### 6.5 Анимационные блоки (`animBlockCount`, максимум 19)
-
-Каждый блок в payload:
-
-```c
-struct AnimBlockRaw {
- uint32_t headerRaw; // mode = headerRaw & 7; interpMask = headerRaw >> 3
- uint16_t keyCount;
- struct KeyRaw {
- uint16_t k0;
- uint16_t k1;
- uint16_t k2;
- } keys[keyCount];
-};
-```
-
-Runtime-представление блока = 16 байт:
-
-```c
-struct AnimBlockRuntime {
- uint32_t mode; // headerRaw & 7
- uint32_t interpMask;// headerRaw >> 3
- int32_t keyCount;
- void* keysPtr; // массив keyCount * 8
-};
-```
-
-Ключи в runtime занимают 8 байт/ключ (с расширением `k0` до `uint32`).
-
-`k2` в `sub_100031F0/sub_10003680` не используется.
-Поле нужно сохранять lossless, т.к. оно присутствует в бинарном формате.
-
-### 6.6 Поиск и fallback
-
-При `LoadMaterial(name)`:
-
-- сначала точный поиск в `Material.lib`;
-- при промахе лог: `"Material %s not found."`;
-- fallback на `DEFAULT`;
-- если и `DEFAULT` не найден, берётся индекс `0`.
-
----
-
-## 7. Выбор текущей material-фазы
-
-### 7.1 Интерполяция (`sub_10003030`)
-
-Интерполируются только следующие поля (по `interpMask`):
-
-- bit `0x02`: `+4,+8,+12`
-- bit `0x01`: `+20,+24,+28`
-- bit `0x04`: `+36,+40,+44`
-- bit `0x08`: `+52,+56,+60`
-- bit `0x10`: `+32`
-
-Не интерполируются и копируются из «текущей» фазы:
-
-- `+0,+16,+48,+64,+68,+72`
-
-### 7.2 Выбор по времени (`sub_100031F0`)
-
-Вход:
-
-- `handle` (`tableIndex|wearIndex`)
-- `animBlockIndex`
-- глобальное время `SetGameTime()` (`dword_10032A38`)
-
-Для каждой wear-записи хранится `startTime` (второй DWORD пары `8-byte`).
-
-Режимы `mode = headerRaw & 7`:
-
-- `0`: loop
-- `1`: ping-pong
-- `2`: one-shot clamp
-- `3`: random (`rand() % cycleLength`)
-
-Важные детали 1:1:
-
-- деление/остаток по циклу реализованы через unsigned `div` (`edx=0` перед делением);
-- в `mode=3` вычисленное `rand() % cycleLength` записывается прямо в `startTime` записи (не в локальную переменную).
-- при `gameTime < startTime` применяется unsigned-wrap семантика (важно для точного воспроизведения edge-case).
-
-После выбора сегмента интерполяции `sub_10003030` строит scratch-материал (`unk_1013B300`), который возвращается через out-параметр.
-
-### 7.3 Выбор по нормализованному `t` (`sub_10003680`)
-
-Аналогично `sub_100031F0`, но time берётся как `t * cycleLength`.
-
-Перед вычислением времени применяется runtime-нормализация:
-
-- если `t < 0.0` или `t > 1.0`, используется `t = 0.5`.
-
-### 7.4 Сброс времени записи
-
-`sub_10003AE0` обновляет `startTime` конкретной wear-записи значением текущего `SetGameTime()`.
-
----
-
-## 8. Формат `WEAR` (текст)
-
-`WEAR` хранится как текст в NRes entry типа `WEAR` (`0x52414557`), обычно имя `*.wea`.
-
-### 8.1 Грамматика
-
-```text
-<wearCount:int>\n
-<legacyId:int> <materialName>\n // повторить wearCount раз
-
-[\n] // для buffer-парсера с LIGHTMAPS фактически обязательна пустая строка
-[LIGHTMAPS\n
-<lightmapCount:int>\n
-<legacyId:int> <lightmapName>\n // повторить lightmapCount раз]
-```
-
-- `<legacyId>` читается, но как ключ не используется.
-- Идентификатором реально является имя (`materialName` / `lightmapName`).
-
-### 8.2 Парсеры
-
-1. `sub_10003B10`: файл/ресурсный режим.
-2. `sub_10003F80`: парсер из строкового буфера.
-
-Различие важно для совместимости:
-
-- `sub_10003B10` после `LIGHTMAPS` сразу читает `lightmapCount` через `fscanf`.
-- `sub_10003F80` после детекта `LIGHTMAPS` делает два последовательных skip до `\n`; поэтому при наличии блока `LIGHTMAPS` нужен пустой разделитель перед строкой `LIGHTMAPS`, иначе парсинг может съехать.
-
-### 8.3 Поведение и ошибки
-
-- `wearCount <= 0` (в текстовом файловом режиме) -> `"Illegal wear length."`
-- при невозможности открыть wear-файл/entry -> `"Wear <%s> doesn't exist."`
-- если найден блок `LIGHTMAPS` и `lightmapCount <= 0` -> `"Illegal lightmaps length."`
-- отсутствующий материал -> `"Material %s not found."` + fallback `DEFAULT`
-- отсутствующая lightmap -> `"LightMap %s not found."` и slot `-1`
-- в buffer-режиме неверная структура вокруг `LIGHTMAPS` может дать некорректный `lightmapCount` и каскадные ошибки чтения.
-
-### 8.4 Ограничения runtime
-
-- Таблиц в `MatManager`: максимум 70 (физический layout).
-- Жёсткой проверки на overflow таблиц в `sub_10003B10/sub_10003F80` нет.
-
-Инструментам нужно явно валидировать `tableCount < 70`.
-
----
-
-## 9. Загрузка texture/lightmap по имени
-
-Общие функции:
-
-- `sub_10004B10` — texture (`Textures.lib`)
-- `sub_10004CB0` — lightmap (`LightMap.lib`)
-
-### 9.1 Валидация имени
-
-Алгоритм требует наличие `'.'` в позиции `0..16`.
-
-Иначе:
-
-- `"Bad texture name."`
-- возврат `-1`
-
-### 9.2 Palette index из суффикса
-
-После точки разбирается:
-
-- `L = toupper(name[dot+1])`
-- `D = name[dot+2]` (опционально)
-- `idx = (L - 'A') * 11 + (D ? (D - '0' + 1) : 0)`
-
-Если `idx < 0`, палитра не подставляется (`0`).
-Верхняя граница `idx` в runtime не проверяется.
-
-Практически в стоковых ассетах имена часто вида `NAME.0`; это даёт `idx < 0`, т.е. без палитровой привязки.
-Для невалидных суффиксов это потенциально даёт OOB-чтение палитрового массива.
-
-### 9.3 Кэширование
-
-- Дедупликация по `resIndex`.
-- При повторном запросе увеличивается `refCount`, `lastZeroRefTime` сбрасывается в `0`.
-- При освобождении материала `refCount` texture/lightmap уменьшается.
-- texture: при `refCount -> 0` запоминается `lastZeroRefTime`; периодический sweep (примерно раз в 20 секунд) удаляет слот, если прошло больше `~60` секунд.
-- lightmap: явного аналогичного sweep-пути нет; освобождение в основном происходит при teardown таблиц (`MatManager` dtor).
-
----
-
-## 10. Формат `Texm`
-
-### 10.1 Заголовок 32 байта
-
-```c
-struct TexmHeader32 {
- uint32_t magic; // 'Texm' = 0x6D786554
- uint32_t width;
- uint32_t height;
- uint32_t mipCount;
- uint32_t flags4;
- uint32_t flags5;
- uint32_t unk6;
- uint32_t format;
-};
-```
-
-### 10.2 Поддерживаемые `format`
-
-Подтверждённые в данных:
-
-- `0` (палитровый 8-bit)
-- `565`
-- `4444`
-- `888`
-- `8888`
-
-Поддерживается loader-ветками Ngi32 (может встречаться в runtime-генерации):
-
-- `556`
-- `88`
-
-### 10.3 Layout payload
-
-1. `TexmHeader32`
-2. если `format == 0`: palette table `256 * 4 = 1024` байта
-3. mip-chain пикселей
-4. опциональный `Page` chunk
-
-Расчёт:
-
-```c
-bytesPerPixel =
- (format == 0) ? 1 :
- (format == 565 || format == 556 || format == 4444 || format == 88) ? 2 :
- 4;
-
-pixelCount = sum_{i=0..mipCount-1}(max(1, width>>i) * max(1, height>>i));
-sizeCore = 32 + (format == 0 ? 1024 : 0) + bytesPerPixel * pixelCount;
-```
-
-### 10.4 `Page` chunk
-
-```c
-struct PageChunk {
- uint32_t magic; // 'Page'
- uint32_t rectCount;
- struct Rect16 {
- int16_t x;
- int16_t w;
- int16_t y;
- int16_t h;
- } rects[rectCount];
-};
-```
-
-Runtime конвертирует `Rect16` в:
-
-- пиксельные прямоугольники;
-- UV-границы с учётом возможного `mipSkip`.
-
-Формулы (`s = mipSkip`):
-
-- `x0 = x << s`, `x1 = (x + w) << s`
-- `y0 = y << s`, `y1 = (y + h) << s`
-- `u0 = x / (width << s)`, `du = w / (width << s)`
-- `v0 = y / (height << s)`, `dv = h / (height << s)`
-
-Также всегда добавляется базовый rect `[0]` на всю текстуру: пиксели `(0,0,width,height)`, UV `(0,0,1,1)`.
-
-### 10.5 Loader-поведение (`sub_1000FB30`)
-
-- Читает header в внутренние поля (`+56..+84`) напрямую:
- - `+56 magic`, `+60 width`, `+64 height`, `+68 mipCount`,
- - `+72 flags4`, `+76 flags5`, `+80 unk6`, `+84 format`.
-- Для `format==0` считывает palette и переставляет каналы в runtime-таблицу.
-- Считает `sizeCore`, находит tail.
-- `Page` разбирается только если включён флаг загрузки `0x400000` и tail содержит `Page`.
-- Может уменьшать стартовый mip (`sub_1000F580`) в зависимости от размеров/формата/флагов.
-- При `DisableMipmap == 0` и допустимых условиях может строить mips в runtime.
-
-### 10.6 Политика `mipSkip` (`sub_1000F580`)
-
-`mipSkip` зависит от `flags5 & 0x72000000`, `width`, `height`, `mipCount`:
-
-- если `mipCount <= 1` -> `0`
-- если `flags5Mask == 0x02000000` -> `2` при `mipCount > 2`, иначе `1`
-- если `flags5Mask == 0x10000000` -> `1`
-- если `flags5Mask == 0x20000000`:
- - `1`, если `width >= 256` или `height >= 256`
- - иначе `0`
-- если `flags5Mask == 0x40000000`:
- - если `width > 128` и `height > 128`: `2` при `mipCount > 2`, иначе `1`
- - если `width == 128` или `height == 128`: `1`
- - иначе `0`
-- иначе `0`
-
-Применение в loader:
-
-- `mipCount -= mipSkip`
-- `width >>= mipSkip`, `height >>= mipSkip`
-- `pixelDataOffset += bytesPerPixel * origWidth * origHeight` для `mipSkip==1`
-- `pixelDataOffset += bytesPerPixel * origWidth * origHeight * 1.25` для `mipSkip==2` (первые два уровня)
-
----
-
-## 11. Флаги профиля/рендера (Ngi32)
-
-Ключ реестра: `HKCU\Software\Nikita\NgiTool`.
-
-Подтверждённые значения:
-
-- `Disable MultiTexturing`
-- `DisableMipmap`
-- `Force 16-bit textures`
-- `UseFirstCard`
-- `DisableD3DCalls`
-- `DisableDSound`
-- `ForceCpu`
-
-Они напрямую влияют на выбор texture format path, mip handling и fallback-ветки.
-
----
-
-## 12. Спецификация для toolchain (read/edit/write)
-
-### 12.1 Каноническая модель данных
-
-1. `MAT0`:
-- хранить исходные `attr1/attr2/attr3`;
-- хранить сырой payload + декодированную структуру;
-- при записи сохранять порядок/размеры секций точно.
-
-2. `WEAR`:
-- хранить строки wear/lightmaps как текст;
-- сохранять порядок строк;
-- допускать отсутствие блока `LIGHTMAPS`.
-- если нужен полный runtime-parity с buffer-парсером (`sub_10003F80`) и есть `LIGHTMAPS`, сохранять пустую строку-разделитель перед строкой `LIGHTMAPS`.
-
-3. `Texm`:
-- хранить header поля как есть (`flags4/flags5/unk6` не нормализовать);
-- хранить palette (если есть), mip data, `Page`.
-
-### 12.2 Правила lossless записи
-
-- Не менять значения `flags4/flags5/unk6` без явной причины.
-- Не менять `NRes` entry attrs, если цель — бинарный round-trip.
-- Для `MAT0`:
- - `animBlockCount < 20`.
- - `phaseCount` и фактический размер секции должны совпадать.
- - textureName в фазе всегда укладывать в 16 байт и NUL-терминировать.
-- Для `Texm`:
- - `magic == 'Texm'`.
- - `mipCount > 0`, `width>0`, `height>0`.
- - tail либо отсутствует, либо ровно один корректный `Page` chunk без лишних байт.
- - при эмуляции runtime-загрузчика учитывать, что `Page` обрабатывается только при load-flag `0x400000`.
-
-### 12.3 Рекомендованные валидации редактора
-
-- `WEAR`:
- - `wearCount > 0`.
- - число строк wear соответствует `wearCount`.
- - если есть `LIGHTMAPS`, то `lightmapCount > 0` и число строк совпадает.
- - для buffer-совместимого текста с `LIGHTMAPS` проверять наличие пустой строки перед `LIGHTMAPS`.
-- `MAT0`:
- - не выходить за payload при распаковке.
- - все ссылки фаз/keys проверять на диапазоны.
-- `Texm`:
- - `sizeCore <= payload_size`.
- - проверка `Page` как `8 + rectCount*8`.
- - предупреждать/блокировать невалидные palette suffix, которые могут дать `idx >= 286` в runtime.
-
----
-
-## 13. Проверка на реальных данных (`tmp/gamedata`)
-
-### 13.1 `Material.lib`
-
-- `905` entries, все `type=MAT0`
-- `attr2 = 6` у всех
-- `attr3 = 0` у всех
-- `phaseCount` до `29`
-- `animBlockCount` до `8` (ограничение runtime `<20` соблюдается)
-
-### 13.2 `Textures.lib`
-
-- `393` entries, все `type=Texm`
-- форматы: `8888(237), 888(52), 565(47), 4444(42), 0(15)`
-- `flags4`: `32(361), 0(32)`
-- `flags5`: `0(312), 0x04000000(81)`
-- `Page` chunk присутствует у `65` текстур
-
-### 13.3 `lightmap.lib`
-
-- `25` entries, все `Texm`
-- формат: `565`
-- `mipCount=1`
-- `flags5`: в основном `0`, встречается `0x00800000`
-
-### 13.4 `WEAR`
-
-- `439` entries `type=WEAR`
-- `attr1=0, attr2=0, attr3=1`
-- `21` entry содержит блок `LIGHTMAPS` (в текущем наборе везде `lightmapCount=1`)
-- для всех `21` entry с `LIGHTMAPS` присутствует пустая строка перед `LIGHTMAPS`.
-
----
-
-## 14. Opaque-поля и границы знания
-
-Для 1:1 runtime/toolchain достаточно фиксировать следующие поля как `opaque-but-required`:
-
-- `MAT0`:
- - `k2` в `AnimBlockRaw::KeyRaw` (хранить/писать без изменений);
- - `metaA/metaB/metaC/metaD` (в `World3D` заполняются и возвращаются наружу; внутренних consumers этих мета-полей не найдено).
-- `Texm`:
- - `flags4/flags5/unk6` (часть веток разобрана, но полная доменная семантика не требуется для 1:1).
-
-Это не блокирует реализацию движка/конвертеров 1:1.
-
----
-
-## 15. Минимальные псевдокоды для реализации
-
-### 15.1 `parse_mat0(payload, attr2)`
-
-```python
-def parse_mat0(payload: bytes, attr2: int):
- cur = 0
- phase_count = u16(payload, cur); cur += 2
- anim_count = u16(payload, cur); cur += 2
- if anim_count >= 20:
- raise ValueError("Too many animations for material")
-
- if attr2 < 2:
- metaA, metaB, metaC, metaD = 255, 255, 0x3F800000, 0
- else:
- metaA = u8(payload, cur); cur += 1
- metaB = u8(payload, cur); cur += 1
- metaC = u32(payload, cur) if attr2 >= 3 else 0x3F800000
- cur += 4 if attr2 >= 3 else 0
- metaD = u32(payload, cur) if attr2 >= 4 else 0
- cur += 4 if attr2 >= 4 else 0
-
- phases = [payload[cur + i*34 : cur + (i+1)*34] for i in range(phase_count)]
- cur += 34 * phase_count
-
- anim = []
- for _ in range(anim_count):
- raw = u32(payload, cur); cur += 4
- key_count = u16(payload, cur); cur += 2
- keys = [payload[cur + k*6 : cur + (k+1)*6] for k in range(key_count)]
- cur += 6 * key_count
- anim.append((raw, keys))
-
- if cur != len(payload):
- raise ValueError("MAT0 tail bytes")
-
- return phase_count, anim_count, metaA, metaB, metaC, metaD, phases, anim
-```
-
-### 15.2 `parse_texm(payload)`
-
-```python
-def parse_texm(payload: bytes):
- magic, w, h, mips, f4, f5, unk6, fmt = unpack_u32x8(payload, 0)
- if magic != 0x6D786554:
- raise ValueError("not Texm")
-
- bpp = 1 if fmt == 0 else (2 if fmt in (565, 556, 4444, 88) else 4)
- pix = 0
- mw, mh = w, h
- for _ in range(mips):
- pix += mw * mh
- mw = max(1, mw >> 1)
- mh = max(1, mh >> 1)
-
- core = 32 + (1024 if fmt == 0 else 0) + bpp * pix
- if core > len(payload):
- raise ValueError("truncated")
-
- page = None
- if core < len(payload):
- if core + 8 > len(payload) or payload[core:core+4] != b"Page":
- raise ValueError("tail without Page")
- n = u32(payload, core + 4)
- need = 8 + n * 8
- if core + need != len(payload):
- raise ValueError("invalid Page size")
- page = [unpack_i16x4(payload, core + 8 + i*8) for i in range(n)]
-
- return (w, h, mips, fmt, f4, f5, unk6, page)
-```
-
-### 15.3 `mip_skip_policy(flags5, width, height, mip_count)`
-
-```python
-def mip_skip_policy(flags5: int, width: int, height: int, mip_count: int) -> int:
- if mip_count <= 1:
- return 0
-
- m = flags5 & 0x72000000
- if m == 0x02000000:
- return 2 if mip_count > 2 else 1
- if m == 0x10000000:
- return 1
- if m == 0x20000000:
- return 1 if (width >= 256 or height >= 256) else 0
- if m == 0x40000000:
- if width > 128 and height > 128:
- return 2 if mip_count > 2 else 1
- if width == 128 or height == 128:
- return 1
- return 0
-```
-
-### 15.4 `parse_wear_buffer_compatible(text)`
-
-```python
-def parse_wear_buffer_compatible(text: str):
- lines = text.splitlines()
- i = 0
-
- wear_count = int(lines[i].strip()); i += 1
- if wear_count <= 0:
- raise ValueError("Illegal wear length.")
-
- wear = []
- for _ in range(wear_count):
- legacy, name = lines[i].split(maxsplit=1)
- wear.append((int(legacy), name.strip()))
- i += 1
-
- lightmaps = []
- tail = lines[i:] if i < len(lines) else []
- if tail and tail[0].strip() == "":
- # sub_10003F80-совместимый разделитель перед LIGHTMAPS
- i += 1
- tail = lines[i:]
-
- if tail and tail[0].strip().upper() == "LIGHTMAPS":
- i += 1
- if i >= len(lines):
- raise ValueError("Illegal lightmaps length.")
- light_count = int(lines[i].strip()); i += 1
- if light_count <= 0:
- raise ValueError("Illegal lightmaps length.")
- for _ in range(light_count):
- legacy, name = lines[i].split(maxsplit=1)
- lightmaps.append((int(legacy), name.strip()))
- i += 1
-
- return wear, lightmaps
-```
-
-### 15.5 `select_phase_time_1to1(...)`
-
-```python
-def select_phase_time_1to1(game_time: int, start_time: int, keys, mode: int):
- # keys: list[(phase_index, t_start, t_end)], t_end последнего = cycle_len
- cycle_len = keys[-1][2]
- if cycle_len <= 0:
- return 0, 0.0
-
- # unsigned div/mod как в runtime
- delta = (game_time - start_time) & 0xFFFFFFFF
- q = delta // cycle_len
- r = delta % cycle_len
-
- if mode == 1: # ping-pong
- if q & 1:
- r = cycle_len - r
- elif mode == 2: # one-shot
- if q > 0:
- k = len(keys) - 1
- return k, 0.0
- elif mode == 3: # random
- r = rand32() % cycle_len
- start_time = r # side effect как в sub_100031F0
-
- k = find_segment(keys, r) # t_start <= r < t_end
- kn = 0 if (k + 1 == len(keys)) else (k + 1)
- t0, t1 = keys[k][1], keys[k][2]
- alpha = 0.0 if t1 == t0 else (r - t0) / float(t1 - t0)
- return (k, kn), alpha
-```
+- [Material (`MAT0`)](material.md)
+- [Wear table (`WEAR`)](wear.md)
+- [Texture (`Texm`)](texture.md)
+- [Render pipeline](render.md)