diff options
| author | Valentin Popov <valentin@popov.link> | 2026-02-12 01:04:43 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-02-12 01:04:43 +0300 |
| commit | 7702d800a0a18c0212cfbf4434d4477eaa76d639 (patch) | |
| tree | 4295969150aa6b66d1ad13f5c20e636b7ee8c24a /docs | |
| parent | 3c06e768d603ea4452fc95cc48951af9270f76a1 (diff) | |
| download | fparkan-7702d800a0a18c0212cfbf4434d4477eaa76d639.tar.xz fparkan-7702d800a0a18c0212cfbf4434d4477eaa76d639.zip | |
feat: улучшить документацию по материалам и текстурам, добавить детали о сборке и парсинге
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/specs/materials-texm.md | 173 |
1 files changed, 165 insertions, 8 deletions
diff --git a/docs/specs/materials-texm.md b/docs/specs/materials-texm.md index f386265..baa80ae 100644 --- a/docs/specs/materials-texm.md +++ b/docs/specs/materials-texm.md @@ -114,11 +114,15 @@ struct TextureSlot { }; ``` +`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` байт) @@ -317,6 +321,7 @@ struct AnimBlockRuntime { Ключи в runtime занимают 8 байт/ключ (с расширением `k0` до `uint32`). `k2` в `sub_100031F0/sub_10003680` не используется. +Поле нужно сохранять lossless, т.к. оно присутствует в бинарном формате. ### 6.6 Поиск и fallback @@ -362,12 +367,22 @@ struct AnimBlockRuntime { - `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()`. @@ -384,6 +399,7 @@ struct AnimBlockRuntime { <wearCount:int>\n <legacyId:int> <materialName>\n // повторить wearCount раз +[\n] // для buffer-парсера с LIGHTMAPS фактически обязательна пустая строка [LIGHTMAPS\n <lightmapCount:int>\n <legacyId:int> <lightmapName>\n // повторить lightmapCount раз] @@ -397,6 +413,11 @@ struct AnimBlockRuntime { 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."` @@ -404,6 +425,7 @@ struct AnimBlockRuntime { - если найден блок `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 @@ -439,14 +461,18 @@ struct AnimBlockRuntime { - `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). --- @@ -521,15 +547,49 @@ 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`). +- Читает 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) @@ -563,6 +623,7 @@ Runtime конвертирует `Rect16` в: - хранить строки wear/lightmaps как текст; - сохранять порядок строк; - допускать отсутствие блока `LIGHTMAPS`. +- если нужен полный runtime-parity с buffer-парсером (`sub_10003F80`) и есть `LIGHTMAPS`, сохранять пустую строку-разделитель перед строкой `LIGHTMAPS`. 3. `Texm`: - хранить header поля как есть (`flags4/flags5/unk6` не нормализовать); @@ -580,6 +641,7 @@ Runtime конвертирует `Rect16` в: - `magic == 'Texm'`. - `mipCount > 0`, `width>0`, `height>0`. - tail либо отсутствует, либо ровно один корректный `Page` chunk без лишних байт. + - при эмуляции runtime-загрузчика учитывать, что `Page` обрабатывается только при load-flag `0x400000`. ### 12.3 Рекомендованные валидации редактора @@ -587,12 +649,14 @@ Runtime конвертирует `Rect16` в: - `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. --- @@ -626,20 +690,21 @@ Runtime конвертирует `Rect16` в: - `439` entries `type=WEAR` - `attr1=0, attr2=0, attr3=1` - `21` entry содержит блок `LIGHTMAPS` (в текущем наборе везде `lightmapCount=1`) +- для всех `21` entry с `LIGHTMAPS` присутствует пустая строка перед `LIGHTMAPS`. --- -## 14. Не до конца определённые семантики +## 14. Opaque-поля и границы знания -Эти поля нужно сохранять прозрачно: +Для 1:1 runtime/toolchain достаточно фиксировать следующие поля как `opaque-but-required`: - `MAT0`: - - `k2` в `AnimBlockRaw::KeyRaw` - - точная доменная семантика `metaA/metaB/metaC/metaD` - - точная семантика части float-полей в `MaterialPhase76` + - `k2` в `AnimBlockRaw::KeyRaw` (хранить/писать без изменений); + - `metaA/metaB/metaC/metaD` (в `World3D` заполняются и возвращаются наружу; внутренних consumers этих мета-полей не найдено). - `Texm`: - - смысл `flags4/flags5/unk6` вне уже наблюдённых веток - - формат `88` в файловом контенте (поддержка есть, но в сток-данных не найден) + - `flags4/flags5/unk6` (часть веток разобрана, но полная доменная семантика не требуется для 1:1). + +Это не блокирует реализацию движка/конвертеров 1:1. --- @@ -715,3 +780,95 @@ def parse_texm(payload: bytes): 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 +``` |
