2026-02-11 21:50:33 +00:00
# Materials, WEAR, MAT0 и Texm
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Документ описывает материальную подсистему движка (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` .
2026-02-11 21:12:05 +00:00
---
2026-02-11 21:50:33 +00:00
## 2. Архитектура подсистемы
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 2.1 Экспортируемые точки входа (World3D)
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- `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` ).
---
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
## 3. Layout `MatManager` (0x470)
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Объект содержит 70 таблиц wear/lightmaps (не 140).
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
```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]
```
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 3.1 Vtable методов (`off_100209E4`)
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
| Индекс | Функция | Назначение |
|---:|---|---|
| 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` для таблицы |
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 3.2 Кодирование material-handle
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
`uint32 handle = (tableIndex << 16) | wearIndex` .
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- `HIWORD(handle)` -> индекс таблицы `0..69`
- `LOWORD(handle)` -> индекс материала в wear-таблице
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
---
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
## 4. Глобальные кэши и их ёмкость
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Ёмкости подтверждены границами циклов/адресов в дизассемблере.
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 4.1 Кэш текстур (`dword_1014E910`...)
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- Размер слота: `5 DWORD` (20 байт)
- Ёмкость: `777`
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
```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 флаги загрузки
};
2026-02-11 21:12:05 +00:00
```
2026-02-11 22:04:43 +00:00
`lastZeroRefTime` реально используется: texture-слоты с `refCount==0` освобождаются отложенно периодическим GC.
2026-02-11 21:50:33 +00:00
### 4.2 Кэш lightmaps (`dword_10029C98`...)
- Тот же layout `5 DWORD`
- Ёмкость: `100`
2026-02-11 22:04:43 +00:00
Для lightmap-слотов аналогичного периодического GC по `lastZeroRefTime` в `World3D` не наблюдается.
2026-02-11 21:50:33 +00:00
### 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 вариантов)
---
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
## 5. Загрузка палитр (`sub_10002B40`)
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 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];
};
2026-02-11 21:12:05 +00:00
```
2026-02-11 21:50:33 +00:00
Если `attr2 < 2` , runtime-значения по умолчанию:
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- `metaA = 255`
- `metaB = 255`
- `metaC = 1.0f` (`0x3F800000` )
- `metaD = 0`
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 6.3 `PhaseRecordByte34` -> runtime `76 bytes`
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Сырые 34 байта:
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
```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 байт)
2026-02-11 21:12:05 +00:00
```c
2026-02-11 21:50:33 +00:00
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
2026-02-11 21:12:05 +00:00
};
```
2026-02-11 21:50:33 +00:00
### 6.5 Анимационные блоки (`animBlockCount`, максимум 19)
Каждый блок в payload:
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
```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];
};
```
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Runtime-представление блока = 16 байт:
2026-02-11 21:12:05 +00:00
```c
2026-02-11 21:50:33 +00:00
struct AnimBlockRuntime {
uint32_t mode; // headerRaw & 7
uint32_t interpMask;// headerRaw >> 3
int32_t keyCount;
void* keysPtr; // массив keyCount * 8
2026-02-11 21:12:05 +00:00
};
```
2026-02-11 21:50:33 +00:00
Ключи в runtime занимают 8 байт/ключ (с расширением `k0` до `uint32` ).
`k2` в `sub_100031F0/sub_10003680` не используется.
2026-02-11 22:04:43 +00:00
Поле нужно сохранять lossless, т.к. оно присутствует в бинарном формате.
2026-02-11 21:50:33 +00:00
### 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`
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 7.2 Выбор по времени (`sub_100031F0`)
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Вход:
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- `handle` (`tableIndex|wearIndex` )
- `animBlockIndex`
- глобальное время `SetGameTime()` (`dword_10032A38` )
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Для каждой wear-записи хранится `startTime` (второй DWORD пары `8-byte` ).
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Режимы `mode = headerRaw & 7` :
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- `0` : loop
- `1` : ping-pong
- `2` : one-shot clamp
- `3` : random (`rand() % cycleLength` )
2026-02-11 21:12:05 +00:00
2026-02-11 22:04:43 +00:00
Важные детали 1:1:
- деление/остаток по циклу реализованы через unsigned `div` (`edx=0` перед делением);
- в `mode=3` вычисленное `rand() % cycleLength` записывается прямо в `startTime` записи (не в локальную переменную).
- при `gameTime < startTime` применяется unsigned-wrap семантика (важно для точного воспроизведения edge-case).
2026-02-11 21:50:33 +00:00
После выбора сегмента интерполяции `sub_10003030` строит scratch-материал (`unk_1013B300` ), который возвращается через out-параметр.
### 7.3 Выбор по нормализованному `t` (`sub_10003680`)
Аналогично `sub_100031F0` , но time берётся как `t * cycleLength` .
2026-02-11 22:04:43 +00:00
Перед вычислением времени применяется runtime-нормализация:
- если `t < 0.0` или `t > 1.0` , используется `t = 0.5` .
2026-02-11 21:50:33 +00:00
### 7.4 С б р о с времени записи
`sub_10003AE0` обновляет `startTime` конкретной wear-записи значением текущего `SetGameTime()` .
2026-02-11 21:12:05 +00:00
---
2026-02-11 21:50:33 +00:00
## 8. Формат `WEAR` (текст)
`WEAR` хранится как текст в NRes entry типа `WEAR` (`0x52414557` ), обычно имя `*.wea` .
### 8.1 Грамматика
```text
<wearCount:int>\n
<legacyId:int> <materialName>\n // повторить wearCount раз
2026-02-11 21:12:05 +00:00
2026-02-11 22:04:43 +00:00
[\n] // для buffer-парсера с LIGHTMAPS фактически обязательна пустая строка
2026-02-11 21:50:33 +00:00
[LIGHTMAPS\n
<lightmapCount:int>\n
<legacyId:int> <lightmapName>\n // повторить lightmapCount раз]
```
- `<legacyId>` читается, но как ключ не используется.
- Идентификатором реально является имя (`materialName` / `lightmapName` ).
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 8.2 Парсеры
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
1. `sub_10003B10` : файл/ресурсный режим.
2. `sub_10003F80` : парсер из строкового буфера.
2026-02-11 21:12:05 +00:00
2026-02-11 22:04:43 +00:00
Различие важно для совместимости:
- `sub_10003B10` после `LIGHTMAPS` сразу читает `lightmapCount` через `fscanf` .
- `sub_10003F80` после детекта `LIGHTMAPS` делает два последовательных skip до `\n` ; поэтому при наличии блока `LIGHTMAPS` нужен пустой разделитель перед строкой `LIGHTMAPS` , иначе парсинг может съехать.
2026-02-11 21:50:33 +00:00
### 8.3 Поведение и ошибки
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- `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`
2026-02-11 22:04:43 +00:00
- в buffer-режиме неверная структура вокруг `LIGHTMAPS` может дать некорректный `lightmapCount` и каскадные ошибки чтения.
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 8.4 Ограничения runtime
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- Таблиц в `MatManager` : максимум 70 (физический layout).
- Жёсткой проверки на overflow таблиц в `sub_10003B10/sub_10003F80` нет.
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Инструментам нужно явно валидировать `tableCount < 70` .
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
---
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
## 9. Загрузка texture/lightmap по имени
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Общие функции:
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- `sub_10004B10` — texture (`Textures.lib` )
- `sub_10004CB0` — lightmap (`LightMap.lib` )
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 9.1 Валидация имени
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Алгоритм требует наличие `'.'` в позиции `0..16` .
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Иначе:
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- `"Bad texture name."`
- возврат `-1`
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 9.2 Palette index из суффикса
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
После точки разбирается:
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- `L = toupper(name[dot+1])`
- `D = name[dot+2]` (опционально)
- `idx = (L - 'A') * 11 + (D ? (D - '0' + 1) : 0)`
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Если `idx < 0` , палитра не подставляется (`0` ).
2026-02-11 22:04:43 +00:00
Верхняя граница `idx` в runtime не проверяется.
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Практически в стоковых ассетах имена часто вида `NAME.0` ; это даёт `idx < 0` , т.е . без палитровой привязки.
2026-02-11 22:04:43 +00:00
Для невалидных суффиксов это потенциально даёт OOB-чтение палитрового массива.
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 9.3 Кэширование
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- Дедупликация по `resIndex` .
- При повторном запросе увеличивается `refCount` , `lastZeroRefTime` сбрасывается в `0` .
- При освобождении материала `refCount` texture/lightmap уменьшается.
2026-02-11 22:04:43 +00:00
- texture: при `refCount -> 0` запоминается `lastZeroRefTime` ; периодический sweep (примерно раз в 20 секунд) удаляет слот, если прошло больше `~60` секунд.
- lightmap: явного аналогичного sweep-пути нет; освобождение в основном происходит при teardown таблиц (`MatManager` dtor).
2026-02-11 21:12:05 +00:00
---
2026-02-11 21:50:33 +00:00
## 10. Формат `Texm`
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 10.1 Заголовок 32 байта
2026-02-11 21:12:05 +00:00
```c
struct TexmHeader32 {
2026-02-11 21:50:33 +00:00
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;
2026-02-11 21:12:05 +00:00
};
```
2026-02-11 21:50:33 +00:00
### 10.2 Поддерживаемые `format`
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Подтверждённые в данных:
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- `0` (палитровый 8-bit)
- `565`
- `4444`
- `888`
- `8888`
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Поддерживается loader-ветками Ngi32 (может встречаться в runtime-генерации):
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- `556`
- `88`
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 10.3 Layout payload
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
1. `TexmHeader32`
2. если `format == 0` : palette table `256 * 4 = 1024` байта
3. mip-chain пикселей
4. опциональный `Page` chunk
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Расчёт:
2026-02-11 21:12:05 +00:00
```c
2026-02-11 21:50:33 +00:00
bytesPerPixel =
(format == 0) ? 1 :
(format == 565 || format == 556 || format == 4444 || format == 88) ? 2 :
4;
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
pixelCount = sum_{i=0..mipCount-1}(max(1, width>>i) * max(1, height>>i));
sizeCore = 32 + (format == 0 ? 1024 : 0) + bytesPerPixel * pixelCount;
```
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
### 10.4 `Page` chunk
2026-02-11 21:12:05 +00:00
```c
struct PageChunk {
2026-02-11 21:50:33 +00:00
uint32_t magic; // 'Page'
uint32_t rectCount;
2026-02-11 21:12:05 +00:00
struct Rect16 {
int16_t x;
int16_t w;
int16_t y;
int16_t h;
2026-02-11 21:50:33 +00:00
} rects[rectCount];
2026-02-11 21:12:05 +00:00
};
```
2026-02-11 21:50:33 +00:00
Runtime конвертирует `Rect16` в:
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- пиксельные прямоугольники;
- UV-границы с учётом возможного `mipSkip` .
2026-02-11 21:12:05 +00:00
2026-02-11 22:04:43 +00:00
Формулы (`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)` .
2026-02-11 21:50:33 +00:00
### 10.5 Loader-поведение (`sub_1000FB30`)
2026-02-11 21:12:05 +00:00
2026-02-11 22:04:43 +00:00
- Читает header в внутренние поля (`+56..+84` ) напрямую:
- `+56 magic` , `+60 width` , `+64 height` , `+68 mipCount` ,
- `+72 flags4` , `+76 flags5` , `+80 unk6` , `+84 format` .
2026-02-11 21:50:33 +00:00
- Для `format==0` считывает palette и переставляет каналы в runtime-таблицу.
- Считает `sizeCore` , находит tail.
- `Page` разбирается только если включён флаг загрузки `0x400000` и tail содержит `Page` .
- Может уменьшать стартовый mip (`sub_1000F580` ) в зависимости от размеров/формата/флагов.
- При `DisableMipmap == 0` и допустимых условиях может строить mips в runtime.
2026-02-11 21:12:05 +00:00
2026-02-11 22:04:43 +00:00
### 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` (первые два уровня)
2026-02-11 21:50:33 +00:00
---
## 11. Флаги профиля/рендера (Ngi32)
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Ключ реестра: `HKCU\Software\Nikita\NgiTool` .
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
Подтверждённые значения:
2026-02-11 21:12:05 +00:00
2026-02-11 21:50:33 +00:00
- `Disable MultiTexturing`
- `DisableMipmap`
- `Force 16-bit textures`
- `UseFirstCard`
- `DisableD3DCalls`
- `DisableDSound`
- `ForceCpu`
Они напрямую влияют на выбор texture format path, mip handling и fallback-ветки.
2026-02-11 21:12:05 +00:00
---
2026-02-11 21:50:33 +00:00
## 12. Спецификация для toolchain (read/edit/write)
### 12.1 Каноническая модель данных
1. `MAT0` :
- хранить исходные `attr1/attr2/attr3` ;
- хранить сырой payload + декодированную структуру;
- при записи сохранять порядок/размеры секций точно.
2. `WEAR` :
- хранить строки wear/lightmaps как текст;
- сохранять порядок строк;
- допускать отсутствие блока `LIGHTMAPS` .
2026-02-11 22:04:43 +00:00
- если нужен полный runtime-parity с buffer-парсером (`sub_10003F80` ) и есть `LIGHTMAPS` , сохранять пустую строку-разделитель перед строкой `LIGHTMAPS` .
2026-02-11 21:50:33 +00:00
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 без лишних байт.
2026-02-11 22:04:43 +00:00
- при эмуляции runtime-загрузчика учитывать, что `Page` обрабатывается только при load-flag `0x400000` .
2026-02-11 21:50:33 +00:00
### 12.3 Рекомендованные валидации редактора
- `WEAR` :
- `wearCount > 0` .
- число строк wear соответствует `wearCount` .
- если есть `LIGHTMAPS` , то `lightmapCount > 0` и число строк совпадает.
2026-02-11 22:04:43 +00:00
- для buffer-совместимого текста с `LIGHTMAPS` проверять наличие пустой строки перед `LIGHTMAPS` .
2026-02-11 21:50:33 +00:00
- `MAT0` :
- не выходить за payload при распаковке.
- все ссылки фаз/keys проверять на диапазоны.
- `Texm` :
- `sizeCore <= payload_size` .
- проверка `Page` как `8 + rectCount*8` .
2026-02-11 22:04:43 +00:00
- предупреждать/блокировать невалидные palette suffix, которые могут дать `idx >= 286` в runtime.
2026-02-11 21:50:33 +00:00
---
## 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` )
2026-02-11 22:04:43 +00:00
- для всех `21` entry с `LIGHTMAPS` присутствует пустая строка перед `LIGHTMAPS` .
2026-02-11 21:50:33 +00:00
---
2026-02-11 22:04:43 +00:00
## 14. Opaque-поля и границы знания
2026-02-11 21:50:33 +00:00
2026-02-11 22:04:43 +00:00
Для 1:1 runtime/toolchain достаточно фиксировать следующие поля как `opaque-but-required` :
2026-02-11 21:50:33 +00:00
- `MAT0` :
2026-02-11 22:04:43 +00:00
- `k2` в `AnimBlockRaw::KeyRaw` (хранить/писать без изменений);
- `metaA/metaB/metaC/metaD` (в `World3D` заполняются и возвращаются наружу; внутренних consumers этих мета-полей не найдено).
2026-02-11 21:50:33 +00:00
- `Texm` :
2026-02-11 22:04:43 +00:00
- `flags4/flags5/unk6` (часть веток разобрана, но полная доменная семантика не требуется для 1:1).
Это не блокирует реализацию движка/конвертеров 1:1.
2026-02-11 21:50:33 +00:00
---
## 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)
```
2026-02-11 22:04:43 +00:00
### 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
```