Compare commits
5 Commits
4c4f542fc2
...
8a69872576
| Author | SHA1 | Date | |
|---|---|---|---|
|
8a69872576
|
|||
|
aa68906a3d
|
|||
|
8bf3b7b209
|
|||
|
669fb40a70
|
|||
|
9c0df3d299
|
@@ -1,21 +1,39 @@
|
|||||||
# MSH animation
|
# MSH animation
|
||||||
|
|
||||||
Документ описывает анимационные ресурсы MSH: `Res8`, `Res19` и runtime-интерполяцию.
|
Документ фиксирует анимационную часть формата MSH (`Res8`, `Res19`) и runtime-алгоритм сэмплирования/смешивания, необходимый для 1:1 совместимого движка и toolchain (reader/writer/converter/editor).
|
||||||
|
|
||||||
|
Связанные документы:
|
||||||
|
- [MSH core](msh-core.md) — общая структура модели и `Res1`/`Res2`.
|
||||||
|
- [NRes / RsLi](nres.md) — контейнер и атрибуты записей.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1.13. Ресурсы анимации: Res8 и Res19
|
## 1. Область и источники
|
||||||
|
|
||||||
- **Res8** — массив анимационных ключей фиксированного размера 24 байта.
|
Спецификация основана на:
|
||||||
- **Res19** — `uint16` mapping‑массив «frame → keyIndex` (с per-node смещением).
|
- `tmp/disassembler1/AniMesh.dll.c` (псевдо-C): `sub_10015FD0`, `sub_10012880`, `sub_10012560`.
|
||||||
|
- `tmp/disassembler2/AniMesh.dll.asm` (ASM): подтверждение x87-пути (`FISTP`) и ветвлений.
|
||||||
|
- `tmp/disassembler1/Ngi32.dll.c` (псевдо-C): `sub_10002F90`, `sub_10014540`, `sub_10014630`, `sub_10015D80`, `sub_10017E60`, `sub_10017F50`, `sub_10006D00`, `niGetProcAddress`.
|
||||||
|
- `tmp/disassembler2/Ngi32.dll.asm` (ASM): подтверждение таблицы `g_FastProc` и FPU control-word setup.
|
||||||
|
- валидации corpus (`testdata`): 435 моделей `*.msh`.
|
||||||
|
|
||||||
### 1.13.1. Формат Res8 (ключ 24 байта)
|
Ниже разделено на:
|
||||||
|
- **Нормативно**: обязательно для runtime-совместимости.
|
||||||
|
- **Канонично**: как устроены исходные ассеты; важно для детерминированного writer/editor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ресурсы и поля модели
|
||||||
|
|
||||||
|
### 2.1. Res8 — key pool (нормативно)
|
||||||
|
|
||||||
|
`Res8` — массив ключей фиксированного шага 24 байта.
|
||||||
|
|
||||||
```c
|
```c
|
||||||
struct AnimKey24 {
|
struct AnimKey24 {
|
||||||
float posX; // +0x00
|
float pos_x; // +0x00
|
||||||
float posY; // +0x04
|
float pos_y; // +0x04
|
||||||
float posZ; // +0x08
|
float pos_z; // +0x08
|
||||||
float time; // +0x0C
|
float time; // +0x0C
|
||||||
int16_t qx; // +0x10
|
int16_t qx; // +0x10
|
||||||
int16_t qy; // +0x12
|
int16_t qy; // +0x12
|
||||||
@@ -27,79 +45,473 @@ struct AnimKey24 {
|
|||||||
Декодирование quaternion-компонент:
|
Декодирование quaternion-компонент:
|
||||||
|
|
||||||
```c
|
```c
|
||||||
q = s16 * (1.0f / 32767.0f)
|
float q = (float)s16 * (1.0f / 32767.0f);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 1.13.2. Формат Res19
|
Атрибуты NRes:
|
||||||
|
- `attr1 = size / 24` (количество ключей).
|
||||||
|
- `attr2 = 0` (в observed corpus).
|
||||||
|
- `attr3 = 4` (не stride; это фактический runtime-инвариант формата).
|
||||||
|
|
||||||
Res19 читается как непрерывный массив `uint16`:
|
### 2.2. Res19 — frame->segment map (нормативно)
|
||||||
|
|
||||||
|
`Res19` — непрерывный `uint16` массив:
|
||||||
|
|
||||||
```c
|
```c
|
||||||
uint16_t map[]; // размер = size(Res19)/2
|
uint16_t map_words[]; // count = size / 2
|
||||||
```
|
```
|
||||||
|
|
||||||
Per-node управление mapping'ом берётся из заголовка узла Res1:
|
Атрибуты NRes:
|
||||||
|
- `attr1 = size / 2` (число `uint16` слов).
|
||||||
|
- `attr2 = animFrameCount` (глобальная длина таймлайна модели в кадрах).
|
||||||
|
- `attr3 = 2`.
|
||||||
|
|
||||||
- `node.hdr2` (`Res1 + 0x04`) = `mapStart` (`0xFFFF` => map отсутствует);
|
### 2.3. Связь с Res1 node header (нормативно)
|
||||||
- `node.hdr3` (`Res1 + 0x06`) = `fallbackKeyIndex` и одновременно верхняя граница валидного `map`‑значения.
|
|
||||||
|
|
||||||
### 1.13.3. Выбор ключа для времени `t` (`sub_10012880`)
|
Для `Res1` со stride 38 (основной формат):
|
||||||
|
- `hdr2` (`node + 0x04`) = `mapStart` (`0xFFFF` => map для узла отсутствует).
|
||||||
|
- `hdr3` (`node + 0x06`) = `fallbackKeyIndex` (индекс ключа в `Res8`).
|
||||||
|
|
||||||
1) Вычислить frame‑индекс:
|
Runtime читает эти поля напрямую в `sub_10012880`.
|
||||||
|
|
||||||
```c
|
### 2.4. Поля runtime-модели, задействованные анимацией (нормативно)
|
||||||
frame = (int64)(t - 0.5f); // x87 FISTP-путь
|
|
||||||
```
|
|
||||||
|
|
||||||
Для строгой 1:1 эмуляции используйте именно поведение x87 `FISTP` (а не «упрощённый floor»), т.к. путь в оригинале опирается на FPU rounding mode.
|
Инициализация в `sub_10015FD0`:
|
||||||
|
- `model+0x18` -> `Res8` pointer.
|
||||||
2) Проверка условий fallback:
|
- `model+0x1C` -> `Res19` pointer.
|
||||||
|
- `model+0x9C` <- `NResEntry(Res19).attr2` (`animFrameCount`).
|
||||||
- `frame >= model.animFrameCount` (`model+0x9C`, из `NResEntry(Res19).attr2`);
|
|
||||||
- `mapStart == 0xFFFF`;
|
|
||||||
- `map[mapStart + frame] >= fallbackKeyIndex`.
|
|
||||||
|
|
||||||
Если любое условие истинно:
|
|
||||||
|
|
||||||
```c
|
|
||||||
keyIndex = fallbackKeyIndex;
|
|
||||||
```
|
|
||||||
|
|
||||||
Иначе:
|
|
||||||
|
|
||||||
```c
|
|
||||||
keyIndex = map[mapStart + frame];
|
|
||||||
```
|
|
||||||
|
|
||||||
3) Сэмплирование:
|
|
||||||
|
|
||||||
- `k0 = Res8[keyIndex]`
|
|
||||||
- `k1 = Res8[keyIndex + 1]` (для интерполяции сегмента)
|
|
||||||
|
|
||||||
Пути:
|
|
||||||
|
|
||||||
- если `t == k0.time` → взять `k0`;
|
|
||||||
- если `t == k1.time` → взять `k1`;
|
|
||||||
- иначе `alpha = (t - k0.time) / (k1.time - k0.time)`, `pos = lerp(k0.pos, k1.pos, alpha)`, rotation смешивается через fastproc‑интерполятор quaternion.
|
|
||||||
|
|
||||||
### 1.13.4. Межкадровое смешивание (`sub_10012560`)
|
|
||||||
|
|
||||||
Функция смешивает два сэмпла (например, из двух animation time-позиций) с коэффициентом `blend`:
|
|
||||||
|
|
||||||
1) получить два `(quat, pos)` через `sub_10012880`;
|
|
||||||
2) выполнить shortest‑path коррекцию знака quaternion:
|
|
||||||
|
|
||||||
```c
|
|
||||||
if (|q0 + q1|^2 < |q0 - q1|^2) q1 = -q1;
|
|
||||||
```
|
|
||||||
|
|
||||||
3) смешать quaternion (fastproc) и построить orientation‑матрицу;
|
|
||||||
4) translation писать отдельно как `lerp(pos0, pos1, blend)` в ячейки `m[3], m[7], m[11]`.
|
|
||||||
|
|
||||||
### 1.13.5. Что хранится в `Res19.attr2`
|
|
||||||
|
|
||||||
При загрузке `sub_10015FD0` записывает `NResEntry(Res19).attr2` в `model+0x9C`.
|
|
||||||
Это поле используется как верхняя граница frame‑индекса в п.1.13.3.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 3. Runtime-сэмплирование узла (`sub_10012880`)
|
||||||
|
|
||||||
|
Функция возвращает:
|
||||||
|
- quaternion (4 float) в буфер `outQuat`,
|
||||||
|
- позицию (3 float) в `outPos`.
|
||||||
|
|
||||||
|
Вход:
|
||||||
|
- `t` — sample time.
|
||||||
|
- текущий `nodeIndex` берётся из runtime-объекта (не из аргумента).
|
||||||
|
|
||||||
|
### 3.1. Вычисление frame index (нормативно)
|
||||||
|
|
||||||
|
Алгоритм:
|
||||||
|
1. `x = t - 0.5`.
|
||||||
|
2. `frame = x87 FISTP(x)` (через 64-битный промежуточный буфер).
|
||||||
|
|
||||||
|
Важно:
|
||||||
|
- это не «просто floor»;
|
||||||
|
- поведение зависит от x87 control word.
|
||||||
|
|
||||||
|
В оригинальном runtime control word приводится к каноничному виду в `Ngi32::sub_10006D00`:
|
||||||
|
- `cw = (cw & 0xF0FF) | 0x003F`;
|
||||||
|
- это даёт `round-to-nearest` (RC=00), precision control `PC=00` и маскирование x87-исключений.
|
||||||
|
|
||||||
|
Если нужен byte/behavior 1:1, надо повторить именно x87-ветку или её точный эквивалент.
|
||||||
|
|
||||||
|
### 3.2. Выбор `keyIndex` (нормативно)
|
||||||
|
|
||||||
|
```c
|
||||||
|
node = Res1 + nodeIndex * 38;
|
||||||
|
mapStart = u16(node + 4); // hdr2
|
||||||
|
fallback = u16(node + 6); // hdr3
|
||||||
|
|
||||||
|
if ((uint32_t)frame >= animFrameCount
|
||||||
|
|| mapStart == 0xFFFF
|
||||||
|
|| map_words[mapStart + (uint32_t)frame] >= fallback) {
|
||||||
|
keyIndex = fallback;
|
||||||
|
} else {
|
||||||
|
keyIndex = map_words[mapStart + (uint32_t)frame];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Критично:
|
||||||
|
- runtime не проверяет bounds у `fallback` и `mapStart + frame`; некорректные данные приводят к OOB.
|
||||||
|
|
||||||
|
### 3.3. Сэмплирование ключей (нормативно)
|
||||||
|
|
||||||
|
`k0 = Res8[keyIndex]`.
|
||||||
|
|
||||||
|
Ветки:
|
||||||
|
1. fallback-ветка из п.3.2: возвращается строго `k0` (без `k1`).
|
||||||
|
2. map-ветка:
|
||||||
|
- если `t == k0.time` -> вернуть `k0`;
|
||||||
|
- иначе берётся `k1 = Res8[keyIndex + 1]`;
|
||||||
|
- если `t == k1.time` -> вернуть `k1`;
|
||||||
|
- иначе:
|
||||||
|
- `alpha = (t - k0.time) / (k1.time - k0.time)`;
|
||||||
|
- `pos = lerp(k0.pos, k1.pos, alpha)`;
|
||||||
|
- `quat = fastproc_interp(k0.quat, k1.quat, alpha)` (`g_FastProc[17]`).
|
||||||
|
|
||||||
|
Сравнение `t == key.time` строгое (битовая float-эквивалентность по FPU compare), без epsilon.
|
||||||
|
|
||||||
|
### 3.4. Порядок quaternion-компонент в runtime (нормативно)
|
||||||
|
|
||||||
|
В `Res8` компоненты лежат как `qx,qy,qz,qw`, но в runtime-буферы они попадают в порядке:
|
||||||
|
- `outQuat[0] = qw`;
|
||||||
|
- `outQuat[1] = qx`;
|
||||||
|
- `outQuat[2] = qy`;
|
||||||
|
- `outQuat[3] = qz`.
|
||||||
|
|
||||||
|
То есть все `g_FastProc`-пути в анимации работают с quaternion в порядке `float4 = [w, x, y, z]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Runtime-смешивание двух сэмплов (`sub_10012560`)
|
||||||
|
|
||||||
|
`sub_10012560(this, tA, tB, blend, outMatrix4x4)` смешивает две позы.
|
||||||
|
|
||||||
|
### 4.1. Валидация входов (нормативно)
|
||||||
|
|
||||||
|
Выбор доступных сэмплов:
|
||||||
|
- `hasA = (blend < 1.0f) && (tA >= 0.0f)`.
|
||||||
|
- `hasB = (blend > 0.0f) && (tB >= 0.0f)`.
|
||||||
|
|
||||||
|
Ветки:
|
||||||
|
- только `hasA`: матрица из A.
|
||||||
|
- только `hasB`: матрица из B.
|
||||||
|
- оба: полноценное смешивание.
|
||||||
|
- ни одного: в оригинале путь не защищён (caller contract).
|
||||||
|
|
||||||
|
### 4.2. Смешивание quaternion (нормативно)
|
||||||
|
|
||||||
|
Перед интерполяцией выполняется shortest-path flip:
|
||||||
|
|
||||||
|
```c
|
||||||
|
if (|qA + qB|^2 < |qA - qB|^2) {
|
||||||
|
qB = -qB;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Далее:
|
||||||
|
- `q = fastproc_blend(qA, qB, blend)` (`g_FastProc[22]`);
|
||||||
|
- `outMatrix = quat_to_matrix(q)` (`g_FastProc[14]`).
|
||||||
|
|
||||||
|
### 4.3. Смешивание translation (нормативно)
|
||||||
|
|
||||||
|
Позиция смешивается отдельно:
|
||||||
|
|
||||||
|
```c
|
||||||
|
pos = (1-blend) * posA + blend * posB;
|
||||||
|
outMatrix[3] = pos.x;
|
||||||
|
outMatrix[7] = pos.y;
|
||||||
|
outMatrix[11] = pos.z;
|
||||||
|
```
|
||||||
|
|
||||||
|
(`sub_1000B8E0` подтверждает, что используются именно эти ячейки).
|
||||||
|
|
||||||
|
### 4.4. Точные `g_FastProc[14/17/22]` (нормативно)
|
||||||
|
|
||||||
|
`niGetProcAddress(i)` в `Ngi32` возвращает `g_FastProc[i]` (таблица function pointers).
|
||||||
|
В `AniMesh` используются:
|
||||||
|
- `call [g_FastProc + 0x38]` -> index 14 -> `quat_to_matrix`.
|
||||||
|
- `call [g_FastProc + 0x44]` -> index 17 -> `quat_interp`.
|
||||||
|
- `call [g_FastProc + 0x58]` -> index 22 -> `quat_blend`.
|
||||||
|
|
||||||
|
Связь с символами `Ngi32` (по адресам таблицы):
|
||||||
|
- `g_FastProc` base = `0x1003A058`;
|
||||||
|
- index 14 -> `0x1003A090`;
|
||||||
|
- index 17 -> `0x1003A09C`;
|
||||||
|
- index 22 -> `0x1003A0B0`.
|
||||||
|
|
||||||
|
Назначения по CPU-веткам (`sub_10002F90`) и семантика:
|
||||||
|
- scalar path: `14=sub_10017E60` (или `sub_10014540`), `17=22=sub_10017F50` (или `sub_10014630`);
|
||||||
|
- SIMD path (`dword_1003A168`): `14=sub_1001D830`, `17=22=sub_10015D80`;
|
||||||
|
- все варианты эквивалентны по математике.
|
||||||
|
|
||||||
|
Точная формула `quat_to_matrix` для `q=[w,x,y,z]`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
m[0] = 1 - 2*(y*y + z*z);
|
||||||
|
m[1] = 2*(x*y + w*z);
|
||||||
|
m[2] = 2*(x*z - w*y);
|
||||||
|
m[3] = 0;
|
||||||
|
|
||||||
|
m[4] = 2*(x*y - w*z);
|
||||||
|
m[5] = 1 - 2*(x*x + z*z);
|
||||||
|
m[6] = 2*(y*z + w*x);
|
||||||
|
m[7] = 0;
|
||||||
|
|
||||||
|
m[8] = 2*(x*z + w*y);
|
||||||
|
m[9] = 2*(y*z - w*x);
|
||||||
|
m[10] = 1 - 2*(x*x + y*y);
|
||||||
|
m[11] = 0;
|
||||||
|
|
||||||
|
m[12] = 0;
|
||||||
|
m[13] = 0;
|
||||||
|
m[14] = 0;
|
||||||
|
m[15] = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
Точная формула `quat_interp`/`quat_blend` (`index 17` и `22`, один и тот же алгоритм):
|
||||||
|
|
||||||
|
```c
|
||||||
|
float dot = dot4(q0, q1);
|
||||||
|
float sign = 1.0f;
|
||||||
|
if (dot < 0.0f) { dot = -dot; sign = -1.0f; }
|
||||||
|
|
||||||
|
float w0, w1;
|
||||||
|
if (1.0f - dot <= 9.9999997e-6f) {
|
||||||
|
w0 = 1.0f - a;
|
||||||
|
w1 = a;
|
||||||
|
} else {
|
||||||
|
float theta = acos(dot);
|
||||||
|
float inv_sin_theta = 1.0f / sin(theta);
|
||||||
|
w1 = sin(a * theta) * inv_sin_theta;
|
||||||
|
w0 = cos(a * theta) - w1 * dot;
|
||||||
|
}
|
||||||
|
w1 *= sign;
|
||||||
|
out = w0 * q0 + w1 * q1;
|
||||||
|
```
|
||||||
|
|
||||||
|
Примечание: явной нормализации `out` в конце нет; используется закрытая форма SLERP-весов.
|
||||||
|
|
||||||
|
Reference pseudocode:
|
||||||
|
|
||||||
|
```c
|
||||||
|
void blend_pose(Model *m, float tA, float tB, float blend, float out_m[16]) {
|
||||||
|
bool hasA = (blend < 1.0f) && (tA >= 0.0f);
|
||||||
|
bool hasB = (blend > 0.0f) && (tB >= 0.0f);
|
||||||
|
|
||||||
|
float qA[4], qB[4], pA[3], pB[3];
|
||||||
|
if (hasA) sample_node_pose(m, m->node_index, tA, qA, pA);
|
||||||
|
if (hasB) sample_node_pose(m, m->node_index, tB, qB, pB);
|
||||||
|
|
||||||
|
if (hasA && !hasB) { quat_to_matrix(qA, out_m); set_translation(out_m, pA); return; }
|
||||||
|
if (!hasA && hasB) { quat_to_matrix(qB, out_m); set_translation(out_m, pB); return; }
|
||||||
|
// !hasA && !hasB: undefined by design, caller does not use this path.
|
||||||
|
|
||||||
|
if (dot4(qA + qB, qA + qB) < dot4(qA - qB, qA - qB)) negate4(qB);
|
||||||
|
float q[4];
|
||||||
|
fastproc_quat_blend(qA, qB, blend, q); // g_FastProc[22]
|
||||||
|
quat_to_matrix(q, out_m); // g_FastProc[14]
|
||||||
|
|
||||||
|
float p[3];
|
||||||
|
p[0] = (1.0f - blend) * pA[0] + blend * pB[0];
|
||||||
|
p[1] = (1.0f - blend) * pA[1] + blend * pB[1];
|
||||||
|
p[2] = (1.0f - blend) * pA[2] + blend * pB[2];
|
||||||
|
out_m[3] = p[0];
|
||||||
|
out_m[7] = p[1];
|
||||||
|
out_m[11] = p[2];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Каноническая модель данных для toolchain
|
||||||
|
|
||||||
|
Ниже правила, по которым удобно строить editor/writer. Они верифицированы на corpus (435 моделей), и совпадают с тем, как устроены оригинальные ассеты.
|
||||||
|
|
||||||
|
### 5.1. Декомпозиция key pool на track-и узлов (канонично)
|
||||||
|
|
||||||
|
Для `Res1` stride 38:
|
||||||
|
- `fallback_i = node[i].hdr3`.
|
||||||
|
- `start_i = (i == 0) ? 0 : (fallback_{i-1} + 1)`.
|
||||||
|
- track узла `i` = `Res8[start_i .. fallback_i]`.
|
||||||
|
|
||||||
|
Наблюдаемые инварианты:
|
||||||
|
- `fallback_i` строго возрастает по `i`.
|
||||||
|
- track всегда непустой (`fallback_i >= start_i`).
|
||||||
|
- для узлов без map (`hdr2 == 0xFFFF`) track длиной ровно 1 ключ.
|
||||||
|
- для узлов с map track длиной минимум 2 ключа.
|
||||||
|
|
||||||
|
### 5.2. Временная ось ключей (канонично)
|
||||||
|
|
||||||
|
В observed corpus:
|
||||||
|
- `time` всех ключей — целые неотрицательные float (`0.0, 1.0, ...`).
|
||||||
|
- внутри track: строго возрастают.
|
||||||
|
- `time(start_i) == 0.0` у каждого узла.
|
||||||
|
- глобальный `Res19.attr2 == max_i(time(fallback_i)) + 1`.
|
||||||
|
|
||||||
|
### 5.3. Компоновка Res19 map-блоков (канонично)
|
||||||
|
|
||||||
|
Если `Res19.size > 0`:
|
||||||
|
- map-блоки есть только у узлов с `hdr2 != 0xFFFF`;
|
||||||
|
- длина блока каждого такого узла: `frameCount = Res19.attr2`;
|
||||||
|
- блоки идут подряд, без дыр и overlap;
|
||||||
|
- итог: `Res19.attr1 == animated_node_count * frameCount`.
|
||||||
|
|
||||||
|
Если модель статическая:
|
||||||
|
- `Res19.size == 0`, `Res19.attr1 == 0`, `Res19.attr2 == 1`, `Res19.attr3 == 2`;
|
||||||
|
- у всех узлов `hdr2 == 0xFFFF`.
|
||||||
|
|
||||||
|
### 5.4. Семантика `map_words[f]` в каноничном writer
|
||||||
|
|
||||||
|
Для кадра `f` и track `keys[start..end]`:
|
||||||
|
- если `f < keys[start].time` или `f >= keys[end].time` -> писать `fallback = end`;
|
||||||
|
- иначе писать индекс левого ключа сегмента (`start <= idx < end`) такого, что:
|
||||||
|
- `keys[idx].time <= f < keys[idx+1].time`.
|
||||||
|
|
||||||
|
В исходных данных fallback-фреймы кодируются значением `== fallback` (не просто `>= fallback`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Reference IR для редактора/конвертера
|
||||||
|
|
||||||
|
Рекомендуемое промежуточное представление:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct NodeAnimTrack {
|
||||||
|
uint32_t node_index;
|
||||||
|
bool has_map; // hdr2 != 0xFFFF
|
||||||
|
uint16_t fallback_key; // hdr3 (derived on write)
|
||||||
|
vector<AnimKey> keys; // local keys for node
|
||||||
|
vector<uint16_t> frame_map; // optional, size == frame_count when has_map
|
||||||
|
};
|
||||||
|
|
||||||
|
struct AnimModel {
|
||||||
|
uint32_t frame_count; // Res19.attr2
|
||||||
|
vector<NodeAnimTrack> tracks; // in node order
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Где `AnimKey`:
|
||||||
|
- `pos: float3`,
|
||||||
|
- `time: float`,
|
||||||
|
- `quat_raw: int16[4]` (для lossless),
|
||||||
|
- `quat_decoded: float4` (опционально для API/UI).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Алгоритм чтения (reader)
|
||||||
|
|
||||||
|
1. Загрузить `Res1`, `Res8`, `Res19`.
|
||||||
|
2. Проверить `Res8.size % 24 == 0`, `Res19.size % 2 == 0`.
|
||||||
|
3. Для каждого узла `i` (stride 38):
|
||||||
|
- взять `hdr2/hdr3`;
|
||||||
|
- вычислить `start_i` через предыдущий `hdr3`;
|
||||||
|
- извлечь `keys[start_i..hdr3]`;
|
||||||
|
- если `hdr2 != 0xFFFF`, взять `frame_map = Res19[hdr2 : hdr2 + frame_count]`.
|
||||||
|
4. Валидировать, что map-значения либо `< hdr3`, либо fallback (`== hdr3` канонично).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Алгоритм записи (writer)
|
||||||
|
|
||||||
|
Нормативный минимум для runtime-совместимости:
|
||||||
|
|
||||||
|
1. Собрать keys всех узлов в один `Res8` pool в node-order.
|
||||||
|
2. Записать `hdr3 = end_index` каждого узла.
|
||||||
|
3. Вычислить `frame_count` и записать в `Res19.attr2`.
|
||||||
|
4. Для узлов с map:
|
||||||
|
- `hdr2 = cursor`;
|
||||||
|
- append `frame_count` слов в `Res19`;
|
||||||
|
- `cursor += frame_count`.
|
||||||
|
5. Для узлов без map: `hdr2 = 0xFFFF`.
|
||||||
|
6. Выставить атрибуты:
|
||||||
|
- `Res8.attr1 = key_count`, `Res8.attr2 = 0`, `Res8.attr3 = 4`;
|
||||||
|
- `Res19.attr1 = map_word_count`, `Res19.attr3 = 2`.
|
||||||
|
|
||||||
|
Каноничный writer (рекомендуется):
|
||||||
|
- генерирует map по правилу §5.4;
|
||||||
|
- fallback-фреймы записывает `== fallback`;
|
||||||
|
- для статических узлов использует 1 ключ (`time=0`, `hdr2=0xFFFF`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Валидация перед сохранением
|
||||||
|
|
||||||
|
Обязательные проверки:
|
||||||
|
|
||||||
|
1. `Res8.size % 24 == 0`, `Res19.size % 2 == 0`.
|
||||||
|
2. Для каждого узла: `fallbackKeyIndex < key_count`.
|
||||||
|
3. Если `hdr2 != 0xFFFF`: `hdr2 + frame_count <= map_word_count`.
|
||||||
|
4. Для map-сегмента узла:
|
||||||
|
- любое значение `< fallback` должно удовлетворять `value + 1 < key_count`.
|
||||||
|
5. В track узла:
|
||||||
|
- `time` строго возрастает;
|
||||||
|
- при наличии map минимум 2 ключа.
|
||||||
|
6. `frame_count > 0` (игровые ассеты используют минимум 1).
|
||||||
|
|
||||||
|
Рекомендуемые проверки (каноничность):
|
||||||
|
|
||||||
|
1. `fallback_i` строго возрастает по узлам.
|
||||||
|
2. track каждого узла начинается с `time == 0`.
|
||||||
|
3. `frame_count == max_end_time + 1`.
|
||||||
|
4. map-блоки узлов без дыр/overlap.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Edge cases и совместимость
|
||||||
|
|
||||||
|
### 10.1. `Res19.size == 0`
|
||||||
|
|
||||||
|
Поддерживается runtime-ом:
|
||||||
|
- `frame_count` обычно 1;
|
||||||
|
- `hdr2 == 0xFFFF` у всех узлов;
|
||||||
|
- сэмплирование всегда через fallback key (`hdr3`).
|
||||||
|
|
||||||
|
### 10.2. Узлы без map
|
||||||
|
|
||||||
|
Это нормальный режим для статических/квазистатических узлов:
|
||||||
|
- `hdr2 = 0xFFFF`;
|
||||||
|
- `hdr3` указывает на единственный ключ узла (канонично).
|
||||||
|
|
||||||
|
### 10.3. `Res1.attr3 == 24` (legacy outlier)
|
||||||
|
|
||||||
|
В corpus встречается единично (`MTCHECK.MSH`, `testdata/nres/system.rlb`):
|
||||||
|
- `Res1.attr3 = 24`;
|
||||||
|
- `Res8` содержит 1 ключ;
|
||||||
|
- `Res19.size == 0`.
|
||||||
|
|
||||||
|
Алгоритм `sub_10012880` адресует node как stride 38, поэтому этот случай нельзя интерпретировать правилами текущего 38-byte формата. Практически это отдельный legacy-формат/legacy-path вне описанного runtime-контракта.
|
||||||
|
|
||||||
|
### 10.4. Квантование quaternion при экспорте
|
||||||
|
|
||||||
|
Для новых данных:
|
||||||
|
- используйте `round(q * 32767)`;
|
||||||
|
- clamp к `[-32767, 32767]` (каноничный диапазон ассетов).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Reference pseudocode (1:1 runtime path)
|
||||||
|
|
||||||
|
```c
|
||||||
|
void sample_node_pose(Model *m, int node_idx, float t, float out_quat[4], float out_pos[3]) {
|
||||||
|
Node38 *node = (Node38 *)((uint8_t *)m->res1 + node_idx * 38);
|
||||||
|
uint16_t map_start = node->hdr2;
|
||||||
|
uint16_t fallback = node->hdr3;
|
||||||
|
uint32_t frame_cnt = m->anim_frame_count; // Res19.attr2
|
||||||
|
|
||||||
|
int32_t frame = x87_fistp_i32((double)t - 0.5); // strict path
|
||||||
|
|
||||||
|
uint16_t key_idx;
|
||||||
|
if ((uint32_t)frame >= frame_cnt ||
|
||||||
|
map_start == 0xFFFF ||
|
||||||
|
m->res19[map_start + (uint32_t)frame] >= fallback) {
|
||||||
|
key_idx = fallback;
|
||||||
|
decode_key_quat_pos(&m->res8[key_idx], out_quat, out_pos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
key_idx = m->res19[map_start + (uint32_t)frame];
|
||||||
|
AnimKey24 *k0 = &m->res8[key_idx];
|
||||||
|
if (t == k0->time) {
|
||||||
|
decode_key_quat_pos(k0, out_quat, out_pos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimKey24 *k1 = &m->res8[key_idx + 1];
|
||||||
|
if (t == k1->time) {
|
||||||
|
decode_key_quat_pos(k1, out_quat, out_pos);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
float a = (t - k0->time) / (k1->time - k0->time);
|
||||||
|
out_pos[0] = lerp(k0->pos_x, k1->pos_x, a);
|
||||||
|
out_pos[1] = lerp(k0->pos_y, k1->pos_y, a);
|
||||||
|
out_pos[2] = lerp(k0->pos_z, k1->pos_z, a);
|
||||||
|
fastproc_quat_interp(decode_quat(k0), decode_quat(k1), a, out_quat); // g_FastProc[17]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Границы полноты
|
||||||
|
|
||||||
|
Для основного формата (`Res1` stride 38 + `Res8` + `Res19`) эта страница покрывает runtime и toolchain-поведение на уровне, достаточном для 1:1 реализации (reader/writer/converter/editor).
|
||||||
|
|
||||||
|
Единственный подтверждённый неполный сегмент:
|
||||||
|
- legacy `Res1.attr3 == 24` (`MTCHECK.MSH`), для которого в `AniMesh` не найден отдельный открытый decode-path в рамках текущего реверса.
|
||||||
|
|
||||||
|
Для абсолютных 100% по всем историческим вариантам формата дополнительно нужно:
|
||||||
|
- найти и дореверсить runtime-код, который реально обрабатывает `Res1.attr3==24` (если он есть в других модулях/ветках);
|
||||||
|
- получить больше образцов `*.msh` с `attr3==24` для проверки writer/validator-инвариантов.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,32 +1,511 @@
|
|||||||
# Terrain + map loading
|
# Terrain + map loading
|
||||||
|
|
||||||
Документ описывает подсистему ландшафта и привязку terrain-данных к миру.
|
Документ описывает полный runtime-пайплайн загрузки ландшафта и карты (`Terrain.dll` + `ArealMap.dll`) и требования к toolchain для 1:1 совместимости (чтение, конвертация, редактирование, обратная сборка).
|
||||||
|
|
||||||
|
Источник реверса:
|
||||||
|
|
||||||
|
- `tmp/disassembler1/Terrain.dll.c`
|
||||||
|
- `tmp/disassembler1/ArealMap.dll.c`
|
||||||
|
- `tmp/disassembler2/Terrain.dll.asm`
|
||||||
|
- `tmp/disassembler2/ArealMap.dll.asm`
|
||||||
|
|
||||||
|
Связанные спецификации:
|
||||||
|
|
||||||
|
- [NRes / RsLi](nres.md)
|
||||||
|
- [MSH core](msh-core.md)
|
||||||
|
- [ArealMap](arealmap.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4.1. Обзор
|
## 1. Назначение подсистем
|
||||||
|
|
||||||
`Terrain.dll` отвечает за рендер ландшафта (terrain), включая:
|
### 1.1. `Terrain.dll`
|
||||||
|
|
||||||
- Рендер мешей ландшафта (`"Rendered meshes"`, `"Rendered primitives"`, `"Rendered faces"`).
|
Отвечает за:
|
||||||
- Рендер частиц (`"Rendered particles/batches"`).
|
|
||||||
- Создание текстур (`"CTexture::CTexture()"` — конструктор текстуры).
|
|
||||||
- Микротекстуры (`"Unable to find microtexture mapping"`).
|
|
||||||
|
|
||||||
## 4.2. Текстуры ландшафта
|
- загрузку и хранение terrain-геометрии из `*.msh` (NRes);
|
||||||
|
- фильтрацию и выборку треугольников для коллизий/трассировки/рендера;
|
||||||
|
- рендер terrain-примитивов и связанного shading;
|
||||||
|
- использование микро-текстурного канала (chunk type 18).
|
||||||
|
|
||||||
В Terrain.dll присутствует конструктор текстуры `CTexture::CTexture()` со следующими проверками:
|
Характерные runtime-строки:
|
||||||
|
|
||||||
- Валидация размера текстуры (`"Unsupported texture size"`).
|
- `CLandscape::CLandscape()`
|
||||||
- Создание D3D‑текстуры (`"Unable to create texture"`).
|
- `Unable to find microtexture mapping chunk`
|
||||||
|
- `Rendering empty primitive!`
|
||||||
|
- `Rendering empty primitive2!`
|
||||||
|
|
||||||
Ландшафт использует **микротекстуры** (micro‑texture mapping chunks) — маленькие повторяющиеся текстуры, тайлящиеся по поверхности.
|
### 1.2. `ArealMap.dll`
|
||||||
|
|
||||||
## 4.3. Защита от пустых примитивов
|
Отвечает за:
|
||||||
|
|
||||||
Terrain.dll содержит проверки:
|
- загрузку геометрии ареалов из `*.map` (NRes, chunk type 12);
|
||||||
|
- построение связей "ареал <-> соседи/подграфы";
|
||||||
|
- grid-ускорение по ячейкам карты;
|
||||||
|
- runtime-доступ к `ISystemArealMap` (интерфейс id `770`) и ареалам (id `771`).
|
||||||
|
|
||||||
- `"Rendering empty primitive!"` — перед первым вызовом отрисовки.
|
Характерные runtime-строки:
|
||||||
- `"Rendering empty primitive2!"` — перед вторым вызовом отрисовки.
|
|
||||||
|
|
||||||
Это подтверждает многопроходный рендер (как минимум 2 прохода для ландшафта).
|
- `SystemArealMap panic: Cannot load ArealMapGeometry`
|
||||||
|
- `SystemArealMap panic: Cannot find chunk in resource`
|
||||||
|
- `SystemArealMap panic: ArealMap Cells are empty`
|
||||||
|
- `SystemArealMap panic: Incorrect ArealMap`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. End-to-End загрузка уровня
|
||||||
|
|
||||||
|
### 2.1. Имена файлов уровня
|
||||||
|
|
||||||
|
В `CLandscape::CLandscape()` базовое имя уровня `levelBase` разворачивается в:
|
||||||
|
|
||||||
|
- `levelBase + ".msh"`: terrain-геометрия;
|
||||||
|
- `levelBase + ".map"`: геометрия ареалов/навигация;
|
||||||
|
- `levelBase + "1.wea"` и `levelBase + "2.wea"`: weather/материалы.
|
||||||
|
|
||||||
|
### 2.2. Порядок инициализации (высокоуровнево)
|
||||||
|
|
||||||
|
1. Получение `3DRender` и `3DSound`.
|
||||||
|
2. Загрузка `MatManager` (`*.wea`), `LightManager`, `CollManager`, `FxManager`.
|
||||||
|
3. Создание `SystemArealMap` через `CreateSystemArealMap(..., "<level>.map", ...)`.
|
||||||
|
4. Открытие terrain-библиотеки `niOpenResFile("<level>.msh")`.
|
||||||
|
5. Загрузка terrain-chunk-ов (см. §3).
|
||||||
|
6. Построение runtime-границ, grid-ускорителей и рабочих массивов.
|
||||||
|
|
||||||
|
Критичные ошибки на любом шаге приводят к `ngiProcessError`/panic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Формат terrain `*.msh` (NRes)
|
||||||
|
|
||||||
|
### 3.1. Используемые chunk type в `Terrain.dll`
|
||||||
|
|
||||||
|
Порядок загрузки в `CLandscape::CLandscape()`:
|
||||||
|
|
||||||
|
| Порядок | Type | Обяз. | Использование (подтверждено кодом) |
|
||||||
|
|---|---:|---|---|
|
||||||
|
| 1 | 3 | да | поток позиций (`stride = 12`) |
|
||||||
|
| 2 | 4 | да | поток packed normal (`stride = 4`) |
|
||||||
|
| 3 | 5 | да | UV-поток (`stride = 4`) |
|
||||||
|
| 4 | 18 | да | microtexture mapping (`stride = 4`) |
|
||||||
|
| 5 | 14 | нет | опциональный доп. поток (`stride = 4`, отсутствует на части карт) |
|
||||||
|
| 6 | 21 | да | таблица terrain-face (по 28 байт) |
|
||||||
|
| 7 | 2 | да | header + slot-таблицы (используются диапазоны face) |
|
||||||
|
| 8 | 1 | да | node/grid-таблица (stride 38) |
|
||||||
|
| 9 | 11 | да | доп. индекс/ускоритель для запросов (cell->list) |
|
||||||
|
|
||||||
|
Ключевые проверки:
|
||||||
|
|
||||||
|
- отсутствие type `18` вызывает `Unable to find microtexture mapping chunk`;
|
||||||
|
- отсутствие остальных обязательных чанков вызывает `Unable to open file`.
|
||||||
|
|
||||||
|
### 3.2. Node/slot структура для terrain
|
||||||
|
|
||||||
|
Terrain-код использует те же stride и адресацию, что и core-описание:
|
||||||
|
|
||||||
|
- node-запись: `38` байт;
|
||||||
|
- slot-запись: `68` байт;
|
||||||
|
- доступ к первому slot-index: `node + 8`;
|
||||||
|
- tri-диапазон в slot: `slot + 140` (offset 0 внутри slot), `slot + 142` (offset 2).
|
||||||
|
|
||||||
|
Это согласуется с [MSH core](msh-core.md) для `Res1/Res2`:
|
||||||
|
|
||||||
|
- `Res1`: `uint16[19]` на node;
|
||||||
|
- `Res2`: header + slot table (`0x8C + N * 0x44`).
|
||||||
|
|
||||||
|
### 3.3. Terrain face record (type 21, 28 bytes)
|
||||||
|
|
||||||
|
Подтвержденные поля из runtime-декодирования face:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct TerrainFace28 {
|
||||||
|
uint32_t flags; // +0
|
||||||
|
uint8_t materialId; // +4 (читается как byte)
|
||||||
|
uint8_t auxByte; // +5
|
||||||
|
uint16_t unk06; // +6
|
||||||
|
uint16_t i0; // +8 (индекс вершины)
|
||||||
|
uint16_t i1; // +10
|
||||||
|
uint16_t i2; // +12
|
||||||
|
uint16_t n0; // +14 (сосед, 0xFFFF -> нет)
|
||||||
|
uint16_t n1; // +16
|
||||||
|
uint16_t n2; // +18
|
||||||
|
int16_t nx; // +20 packed normal component
|
||||||
|
int16_t ny; // +22
|
||||||
|
int16_t nz; // +24
|
||||||
|
uint8_t edgeClass; // +26 (три 2-бит значения)
|
||||||
|
uint8_t unk27; // +27
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`edgeClass` декодируется как:
|
||||||
|
|
||||||
|
- `edge0 = byte26 & 0x3`
|
||||||
|
- `edge1 = (byte26 >> 2) & 0x3`
|
||||||
|
- `edge2 = (byte26 >> 4) & 0x3`
|
||||||
|
|
||||||
|
### 3.4. Маски флагов face
|
||||||
|
|
||||||
|
Во многих запросах применяется фильтр:
|
||||||
|
|
||||||
|
```c
|
||||||
|
(faceFlags & requiredMask) == requiredMask &&
|
||||||
|
(faceFlags | ~forbiddenMask) == ~forbiddenMask
|
||||||
|
```
|
||||||
|
|
||||||
|
Эквивалентно: "все required-биты выставлены, forbidden-биты отсутствуют".
|
||||||
|
|
||||||
|
Подтверждено активное использование битов:
|
||||||
|
|
||||||
|
- `0x8` (особая обработка в трассировке)
|
||||||
|
- `0x2000`
|
||||||
|
- `0x20000`
|
||||||
|
- `0x100000`
|
||||||
|
- `0x200000`
|
||||||
|
|
||||||
|
Кроме "полной" 32-бит маски, runtime использует компактные маски в API-запросах.
|
||||||
|
|
||||||
|
Подтверждённый remap `full -> compactMain16` (функции `sub_10013FC0`, `sub_1004BA00`, `sub_1004BB40`):
|
||||||
|
|
||||||
|
| Full bit | Compact bit |
|
||||||
|
|---:|---:|
|
||||||
|
| `0x00000001` | `0x0001` |
|
||||||
|
| `0x00000008` | `0x0002` |
|
||||||
|
| `0x00000010` | `0x0004` |
|
||||||
|
| `0x00000020` | `0x0008` |
|
||||||
|
| `0x00001000` | `0x0010` |
|
||||||
|
| `0x00004000` | `0x0020` |
|
||||||
|
| `0x00000002` | `0x0040` |
|
||||||
|
| `0x00000400` | `0x0080` |
|
||||||
|
| `0x00000800` | `0x0100` |
|
||||||
|
| `0x00020000` | `0x0200` |
|
||||||
|
| `0x00002000` | `0x0400` |
|
||||||
|
| `0x00000200` | `0x0800` |
|
||||||
|
| `0x00000004` | `0x1000` |
|
||||||
|
| `0x00000040` | `0x2000` |
|
||||||
|
| `0x00200000` | `0x8000` |
|
||||||
|
|
||||||
|
Подтверждённый remap `full -> compactMaterial6` (функции `sub_10014090`, `sub_10015540`, `sub_1004BB40`):
|
||||||
|
|
||||||
|
| Full bit | Compact bit |
|
||||||
|
|---:|---:|
|
||||||
|
| `0x00000100` | `0x01` |
|
||||||
|
| `0x00008000` | `0x02` |
|
||||||
|
| `0x00010000` | `0x04` |
|
||||||
|
| `0x00040000` | `0x08` |
|
||||||
|
| `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]`.
|
||||||
|
|
||||||
|
Для toolchain это означает:
|
||||||
|
|
||||||
|
- если редактируется только бинарник `type 21`, достаточно сохранять `flags` как есть;
|
||||||
|
- если реализуется API-совместимый runtime-слой, нужно поддерживать оба представления (`full` и `compact`) и точный remap выше.
|
||||||
|
|
||||||
|
### 3.5. Grid-ускоритель terrain-запросов
|
||||||
|
|
||||||
|
Runtime строит grid descriptor с параметрами:
|
||||||
|
|
||||||
|
- origin (`baseX/baseY`);
|
||||||
|
- масштабные коэффициенты (`invSizeX/invSizeY`);
|
||||||
|
- размеры сетки (`cellsX`, `cellsY`).
|
||||||
|
|
||||||
|
Дальше запросы:
|
||||||
|
|
||||||
|
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-массивы)
|
||||||
|
|
||||||
|
В `CLandscape` после инициализации используются три параллельных массива по ячейкам (`cellsX * cellsY`):
|
||||||
|
|
||||||
|
- `this+31588` (`sub_100164B0` ctor): массив записей по `12` байт, каждая запись содержит динамический буфер `8`-байтовых элементов;
|
||||||
|
- `this+31592` (`sub_100164E0` ctor): массив записей по `12` байт, каждая запись содержит динамический буфер `4`-байтовых элементов;
|
||||||
|
- `this+31596` (`sub_1001F880` ctor): массив записей по `12` байт для runtime-объектов/агентов (буфер `4`-байтовых идентификаторов/указателей).
|
||||||
|
|
||||||
|
Общий header записи списка:
|
||||||
|
|
||||||
|
```c
|
||||||
|
struct CellListHdr {
|
||||||
|
void* ptr; // +0
|
||||||
|
int count; // +4
|
||||||
|
int capacity; // +8
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Подтвержденные 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: ...`.
|
||||||
|
|
||||||
|
### 4.2. Верхний уровень chunk 12
|
||||||
|
|
||||||
|
Используются:
|
||||||
|
|
||||||
|
- `entry.attr1` (из каталога NRes) как `areal_count`;
|
||||||
|
- `entry[+0x0C]` как размер payload chunk для контроля полного разбора.
|
||||||
|
|
||||||
|
Данные chunk:
|
||||||
|
|
||||||
|
1. `areal_count` переменных записей ареалов;
|
||||||
|
2. секция grid-ячеек (`cellsX/cellsY` + списки попаданий).
|
||||||
|
|
||||||
|
### 4.3. Переменная запись ареала
|
||||||
|
|
||||||
|
Полностью подтверждённые элементы layout:
|
||||||
|
|
||||||
|
```c
|
||||||
|
// record = начало записи ареала
|
||||||
|
float anchor_x = *(float*)(record + 0);
|
||||||
|
float anchor_y = *(float*)(record + 4);
|
||||||
|
float anchor_z = *(float*)(record + 8);
|
||||||
|
float reserved_12 = *(float*)(record + 12); // в retail-данных всегда 0
|
||||||
|
float area_metric = *(float*)(record + 16); // предрасчитанная площадь ареала
|
||||||
|
float normal_x = *(float*)(record + 20);
|
||||||
|
float normal_y = *(float*)(record + 24);
|
||||||
|
float normal_z = *(float*)(record + 28); // unit vector (|n| ~= 1)
|
||||||
|
uint32_t logic_flag = *(uint32_t*)(record + 32); // активно используется в runtime
|
||||||
|
uint32_t reserved_36 = *(uint32_t*)(record + 36); // в retail-данных всегда 0
|
||||||
|
uint32_t class_id = *(uint32_t*)(record + 40); // runtime-class/type id ареала
|
||||||
|
uint32_t reserved_44 = *(uint32_t*)(record + 44); // в retail-данных всегда 0
|
||||||
|
uint32_t vertex_count = *(uint32_t*)(record + 48);
|
||||||
|
uint32_t poly_count = *(uint32_t*)(record + 52);
|
||||||
|
float* vertices = (float*)(record + 56); // float3[vertex_count]
|
||||||
|
|
||||||
|
// сразу после vertices:
|
||||||
|
// EdgeLink8[vertex_count + 3*poly_count]
|
||||||
|
// где EdgeLink8 = { int32_t area_ref; int32_t edge_ref; }
|
||||||
|
// первые vertex_count записей используются как per-edge соседство границы ареала.
|
||||||
|
EdgeLink8* links = (EdgeLink8*)(record + 56 + 12 * vertex_count);
|
||||||
|
|
||||||
|
uint8_t* p = (uint8_t*)(links + (vertex_count + 3 * poly_count));
|
||||||
|
for (i=0; i<poly_count; i++) {
|
||||||
|
uint32_t n = *(uint32_t*)p;
|
||||||
|
p += 4 * (3*n + 1);
|
||||||
|
}
|
||||||
|
// p -> начало следующей записи ареала
|
||||||
|
```
|
||||||
|
|
||||||
|
То есть для toolchain:
|
||||||
|
|
||||||
|
- поля `+0/+4/+8`, `+16`, `+20..+28`, `+32`, `+40`, `+48`, `+52` являются runtime-значимыми;
|
||||||
|
- для `links[0..vertex_count-1]` подтверждена интерпретация как `(area_ref, edge_ref)`:
|
||||||
|
- `area_ref == -1 && edge_ref == -1` = нет соседа;
|
||||||
|
- иначе `area_ref` указывает на индекс ареала, `edge_ref` — на индекс ребра в целевом ареале;
|
||||||
|
- при редактировании безопасно работать через parser+writer этой формулы;
|
||||||
|
- неизвестные байты внутри записи должны сохраняться без изменений.
|
||||||
|
|
||||||
|
Дополнительно по runtime-поведению:
|
||||||
|
|
||||||
|
- `anchor_x/anchor_y` валидируются на попадание внутрь полигона; при промахе движок делает случайный re-seed позиции (см. §4.5);
|
||||||
|
- `logic_flag` по смещению `+32` используется как gating-условие в логике `SystemArealMap`.
|
||||||
|
|
||||||
|
### 4.4. Секция grid-ячеек в chunk 12
|
||||||
|
|
||||||
|
После массива ареалов идёт:
|
||||||
|
|
||||||
|
```c
|
||||||
|
uint32_t cellsX;
|
||||||
|
uint32_t cellsY;
|
||||||
|
for (x in 0..cellsX-1) {
|
||||||
|
for (y in 0..cellsY-1) {
|
||||||
|
uint16_t hitCount;
|
||||||
|
uint16_t areaIds[hitCount];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Runtime упаковывает метаданные ячейки в `uint32`:
|
||||||
|
|
||||||
|
- high 10 bits: `hitCount` (`value >> 22`);
|
||||||
|
- low 22 bits: `startIndex` (1-based индекс в общем `uint16`-пуле areaIds).
|
||||||
|
|
||||||
|
Контроль целостности:
|
||||||
|
|
||||||
|
- после разбора `ptr_end - chunk_begin` должен строго совпасть с `entry[+0x0C]`;
|
||||||
|
- иначе `SystemArealMap panic: Incorrect ArealMap`.
|
||||||
|
|
||||||
|
### 4.5. Нормализация геометрии при загрузке
|
||||||
|
|
||||||
|
Если опорная точка ареала не попадает внутрь его полигона:
|
||||||
|
|
||||||
|
- до 100 попыток случайного сдвига в радиусе ~30;
|
||||||
|
- затем до 200 попыток в радиусе ~100.
|
||||||
|
|
||||||
|
Это runtime-correction; для 1:1-офлайн инструментов лучше генерировать валидные данные, чтобы не зависеть от недетерминизма `rand()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. `BuildDat.lst` и объектные категории ареалов
|
||||||
|
|
||||||
|
`ArealMap.dll` инициализирует 12 категорий и читает `BuildDat.lst`.
|
||||||
|
|
||||||
|
Хардкод-категории (имя -> mask):
|
||||||
|
|
||||||
|
| Имя | Маска |
|
||||||
|
|---|---:|
|
||||||
|
| `Bunker_Small` | `0x80010000` |
|
||||||
|
| `Bunker_Medium` | `0x80020000` |
|
||||||
|
| `Bunker_Large` | `0x80040000` |
|
||||||
|
| `Generator` | `0x80000002` |
|
||||||
|
| `Mine` | `0x80000004` |
|
||||||
|
| `Storage` | `0x80000008` |
|
||||||
|
| `Plant` | `0x80000010` |
|
||||||
|
| `Hangar` | `0x80000040` |
|
||||||
|
| `MainTeleport` | `0x80000200` |
|
||||||
|
| `Institute` | `0x80000400` |
|
||||||
|
| `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"
|
||||||
|
|
||||||
|
Для runtime-совместимого движка нужно реализовать:
|
||||||
|
|
||||||
|
1. NRes API-уровень (`niOpenResFile`, `niOpenResInMem`, поиск chunk по type, получение data/attrs).
|
||||||
|
2. `CLandscape` пайплайн загрузки `*.msh` + менеджеров + `CreateSystemArealMap`.
|
||||||
|
3. Terrain face decode (28-byte запись), mask-фильтр, spatial grid queries.
|
||||||
|
4. Загрузчик `ArealMapGeometry` (chunk 12) с той же валидацией и packed-cell логикой.
|
||||||
|
5. Пост-обработку ареалов (пересвязка, корректировки опорных точек).
|
||||||
|
6. Поддержку `BuildDat.lst` для объектных категорий/схем.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Нерасшифрованные зоны (важно для редакторов)
|
||||||
|
|
||||||
|
Ниже поля, которые пока нельзя безопасно "пересобирать по смыслу":
|
||||||
|
|
||||||
|
- семантика `class_id` (`record + 40`) на уровне геймдизайна/скриптов (числовое поле подтверждено, но человекочитаемая таблица соответствий не восстановлена полностью);
|
||||||
|
- ветки формата для `poly_count > 0` (в retail `tmp/gamedata` это всегда `0`, поэтому поведение этих веток подтверждено только по коду, без живых образцов);
|
||||||
|
- человекочитаемая семантика части битов `TerrainFace28.flags` (при этом remap и бинарные значения подтверждены);
|
||||||
|
- семантика поля `aux` во `8`-байтовом элементе cell-списка (`this+31588`, второй `uint32_t`), которое в известных runtime-путях инициализируется нулем.
|
||||||
|
|
||||||
|
Правило до полного реверса: `preserve-as-is`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Эмпирическая верификация (retail `tmp/gamedata`)
|
||||||
|
|
||||||
|
Для массовой проверки спецификации добавлен валидатор:
|
||||||
|
|
||||||
|
- `tools/terrain_map_doc_validator.py`
|
||||||
|
|
||||||
|
Запуск:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/terrain_map_doc_validator.py \
|
||||||
|
--maps-root tmp/gamedata/DATA/MAPS \
|
||||||
|
--report-json tmp/terrain_map_doc_validator.report.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверенные инварианты (на 33 картах, 2026-02-12):
|
||||||
|
|
||||||
|
- `Land.msh`:
|
||||||
|
- порядок 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`.
|
||||||
|
|||||||
262
tools/fxid_abs100_audit.py
Normal file
262
tools/fxid_abs100_audit.py
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Deterministic audit for FXID "absolute parity" checklist.
|
||||||
|
|
||||||
|
What this script produces:
|
||||||
|
1) strict parsing stats across all FXID payloads in NRes archives,
|
||||||
|
2) opcode histogram and rare-branch counters (op6, op1 tail usage),
|
||||||
|
3) reference vectors for RNG core (sub_10002220 semantics).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import struct
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import archive_roundtrip_validator as arv
|
||||||
|
|
||||||
|
TYPE_FXID = 0x44495846
|
||||||
|
FX_CMD_SIZE = {1: 224, 2: 148, 3: 200, 4: 204, 5: 112, 6: 4, 7: 208, 8: 248, 9: 208, 10: 208}
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes:
|
||||||
|
start = int(entry["data_offset"])
|
||||||
|
end = start + int(entry["size"])
|
||||||
|
return blob[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def _cstr32(raw: bytes) -> str:
|
||||||
|
return raw.split(b"\x00", 1)[0].decode("latin1", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _rng_step_sub_10002220(state32: int) -> tuple[int, int]:
|
||||||
|
"""
|
||||||
|
sub_10002220 semantics in 32-bit packed state form:
|
||||||
|
lo = state[15:0], hi = state[31:16]
|
||||||
|
new_lo = hi ^ (lo << 1)
|
||||||
|
new_hi = (hi >> 1) ^ new_lo
|
||||||
|
return new_hi (u16), update state=(new_hi<<16)|new_lo
|
||||||
|
"""
|
||||||
|
lo = state32 & 0xFFFF
|
||||||
|
hi = (state32 >> 16) & 0xFFFF
|
||||||
|
new_lo = (hi ^ ((lo << 1) & 0xFFFF)) & 0xFFFF
|
||||||
|
new_hi = ((hi >> 1) ^ new_lo) & 0xFFFF
|
||||||
|
return ((new_hi << 16) | new_lo), new_hi
|
||||||
|
|
||||||
|
|
||||||
|
def _rng_vectors() -> dict[str, Any]:
|
||||||
|
seeds = [0x00000000, 0x00000001, 0x12345678, 0x89ABCDEF, 0xFFFFFFFF]
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
for seed in seeds:
|
||||||
|
state = seed
|
||||||
|
outputs: list[int] = []
|
||||||
|
states: list[int] = []
|
||||||
|
for _ in range(16):
|
||||||
|
state, value = _rng_step_sub_10002220(state)
|
||||||
|
outputs.append(value)
|
||||||
|
states.append(state)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"seed_hex": f"0x{seed:08X}",
|
||||||
|
"outputs_u16_hex": [f"0x{x:04X}" for x in outputs],
|
||||||
|
"states_u32_hex": [f"0x{x:08X}" for x in states],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"generator": "sub_10002220", "vectors": out}
|
||||||
|
|
||||||
|
|
||||||
|
def run_audit(root: Path) -> dict[str, Any]:
|
||||||
|
counters: Counter[str] = Counter()
|
||||||
|
opcode_hist: Counter[int] = Counter()
|
||||||
|
issues: list[dict[str, Any]] = []
|
||||||
|
op1_tail6_samples: list[dict[str, Any]] = []
|
||||||
|
op1_optref_samples: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for item in arv.scan_archives(root):
|
||||||
|
if item["type"] != "nres":
|
||||||
|
continue
|
||||||
|
archive_path = root / item["relative_path"]
|
||||||
|
counters["archives_total"] += 1
|
||||||
|
data = archive_path.read_bytes()
|
||||||
|
try:
|
||||||
|
parsed = arv.parse_nres(data, source=str(archive_path))
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": None,
|
||||||
|
"message": f"cannot parse NRes: {exc}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for entry in parsed["entries"]:
|
||||||
|
if int(entry["type_id"]) != TYPE_FXID:
|
||||||
|
continue
|
||||||
|
counters["fxid_total"] += 1
|
||||||
|
payload = _entry_payload(data, entry)
|
||||||
|
entry_name = str(entry["name"])
|
||||||
|
|
||||||
|
if len(payload) < 60:
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": entry_name,
|
||||||
|
"message": f"payload too small: {len(payload)}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
cmd_count = struct.unpack_from("<I", payload, 0)[0]
|
||||||
|
ptr = 0x3C
|
||||||
|
ok = True
|
||||||
|
for idx in range(cmd_count):
|
||||||
|
if ptr + 4 > len(payload):
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": entry_name,
|
||||||
|
"message": f"command {idx}: missing header at offset={ptr}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
|
||||||
|
word = struct.unpack_from("<I", payload, ptr)[0]
|
||||||
|
opcode = word & 0xFF
|
||||||
|
size = FX_CMD_SIZE.get(opcode)
|
||||||
|
if size is None:
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": entry_name,
|
||||||
|
"message": f"command {idx}: unknown opcode={opcode} at offset={ptr}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if ptr + size > len(payload):
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": entry_name,
|
||||||
|
"message": f"command {idx}: truncated end={ptr + size}, payload={len(payload)}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ok = False
|
||||||
|
break
|
||||||
|
|
||||||
|
opcode_hist[opcode] += 1
|
||||||
|
if opcode == 6:
|
||||||
|
counters["op6_commands"] += 1
|
||||||
|
if opcode == 1:
|
||||||
|
tail6 = payload[ptr + 136 : ptr + 160]
|
||||||
|
if any(tail6):
|
||||||
|
counters["op1_tail6_nonzero"] += 1
|
||||||
|
if len(op1_tail6_samples) < 16:
|
||||||
|
dwords = list(struct.unpack("<6I", tail6))
|
||||||
|
op1_tail6_samples.append(
|
||||||
|
{
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": entry_name,
|
||||||
|
"cmd_index": idx,
|
||||||
|
"tail6_u32_hex": [f"0x{x:08X}" for x in dwords],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
archive_s = _cstr32(payload[ptr + 160 : ptr + 192])
|
||||||
|
name_s = _cstr32(payload[ptr + 192 : ptr + 224])
|
||||||
|
if archive_s or name_s:
|
||||||
|
counters["op1_optref_nonempty"] += 1
|
||||||
|
if len(op1_optref_samples) < 16:
|
||||||
|
op1_optref_samples.append(
|
||||||
|
{
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": entry_name,
|
||||||
|
"cmd_index": idx,
|
||||||
|
"opt_archive": archive_s,
|
||||||
|
"opt_name": name_s,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ptr += size
|
||||||
|
|
||||||
|
if ok and ptr != len(payload):
|
||||||
|
issues.append(
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"archive": str(archive_path),
|
||||||
|
"entry": entry_name,
|
||||||
|
"message": f"tail bytes after command stream: parsed_end={ptr}, payload={len(payload)}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
if ok:
|
||||||
|
counters["fxid_ok"] += 1
|
||||||
|
|
||||||
|
return {
|
||||||
|
"input_root": str(root),
|
||||||
|
"summary": {
|
||||||
|
"archives_total": counters["archives_total"],
|
||||||
|
"fxid_total": counters["fxid_total"],
|
||||||
|
"fxid_ok": counters["fxid_ok"],
|
||||||
|
"issues_total": len(issues),
|
||||||
|
"op6_commands": counters["op6_commands"],
|
||||||
|
"op1_tail6_nonzero": counters["op1_tail6_nonzero"],
|
||||||
|
"op1_optref_nonempty": counters["op1_optref_nonempty"],
|
||||||
|
},
|
||||||
|
"opcode_histogram": {str(k): opcode_hist[k] for k in sorted(opcode_hist)},
|
||||||
|
"op1_tail6_samples": op1_tail6_samples,
|
||||||
|
"op1_optref_samples": op1_optref_samples,
|
||||||
|
"rng_reference": _rng_vectors(),
|
||||||
|
"rng_states_fx_path": [
|
||||||
|
{"state": "dword_10023688", "seed_init": "sub_10002660", "used_by": ["sub_10001720", "sub_10001A40"]},
|
||||||
|
{"state": "dword_100238C0", "seed_init": "sub_10003A50", "used_by": ["sub_10002BE0"]},
|
||||||
|
{"state": "dword_10024110", "seed_init": "sub_10009180", "used_by": ["sub_10008120", "sub_10007D10"]},
|
||||||
|
{"state": "dword_10024810", "seed_init": "sub_1000D370", "used_by": ["sub_1000BF30", "sub_1000C1A0"]},
|
||||||
|
{"state": "dword_10024A48", "seed_init": "sub_1000F420", "used_by": ["sub_1000EC50"]},
|
||||||
|
{"state": "dword_10024C80", "seed_init": "sub_10010370", "used_by": ["sub_1000F6E0"]},
|
||||||
|
{"state": "dword_100250F0", "seed_init": "sub_10012C70", "used_by": ["sub_10011230", "sub_100115C0"]},
|
||||||
|
],
|
||||||
|
"issues": issues,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(description="FXID absolute parity audit.")
|
||||||
|
parser.add_argument("--input", required=True, help="Root directory with game/test archives.")
|
||||||
|
parser.add_argument("--report", required=True, help="Output JSON report path.")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
root = Path(args.input).resolve()
|
||||||
|
report_path = Path(args.report).resolve()
|
||||||
|
payload = run_audit(root)
|
||||||
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
report_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
summary = payload["summary"]
|
||||||
|
print(f"Input root : {root}")
|
||||||
|
print(f"NRes archives : {summary['archives_total']}")
|
||||||
|
print(f"FXID payloads : {summary['fxid_ok']}/{summary['fxid_total']} valid")
|
||||||
|
print(f"Issues : {summary['issues_total']}")
|
||||||
|
print(f"Opcode6 commands : {summary['op6_commands']}")
|
||||||
|
print(f"Op1 tail6 nonzero : {summary['op1_tail6_nonzero']}")
|
||||||
|
print(f"Op1 optref non-empty : {summary['op1_optref_nonempty']}")
|
||||||
|
print(f"Report : {report_path}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
809
tools/terrain_map_doc_validator.py
Normal file
809
tools/terrain_map_doc_validator.py
Normal file
@@ -0,0 +1,809 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Validate terrain/map documentation assumptions against real game data.
|
||||||
|
|
||||||
|
Targets:
|
||||||
|
- tmp/gamedata/DATA/MAPS/**/Land.msh
|
||||||
|
- tmp/gamedata/DATA/MAPS/**/Land.map
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
from collections import Counter, defaultdict
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import archive_roundtrip_validator as arv
|
||||||
|
|
||||||
|
MAGIC_NRES = b"NRes"
|
||||||
|
|
||||||
|
REQUIRED_MSH_TYPES = (1, 2, 3, 4, 5, 11, 18, 21)
|
||||||
|
OPTIONAL_MSH_TYPES = (14,)
|
||||||
|
EXPECTED_MSH_ORDER = (1, 2, 3, 4, 5, 18, 14, 11, 21)
|
||||||
|
|
||||||
|
MSH_STRIDES = {
|
||||||
|
1: 38,
|
||||||
|
3: 12,
|
||||||
|
4: 4,
|
||||||
|
5: 4,
|
||||||
|
11: 4,
|
||||||
|
14: 4,
|
||||||
|
18: 4,
|
||||||
|
21: 28,
|
||||||
|
}
|
||||||
|
|
||||||
|
SLOT_TABLE_OFFSET = 0x8C
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ValidationIssue:
|
||||||
|
severity: str # error | warning
|
||||||
|
category: str
|
||||||
|
resource: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class TerrainMapDocValidator:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.issues: list[ValidationIssue] = []
|
||||||
|
self.stats: dict[str, Any] = {
|
||||||
|
"maps_total": 0,
|
||||||
|
"msh_total": 0,
|
||||||
|
"map_total": 0,
|
||||||
|
"msh_type_orders": Counter(),
|
||||||
|
"msh_attr_triplets": defaultdict(Counter), # type_id -> Counter[(a1,a2,a3)]
|
||||||
|
"msh_type11_header_words": Counter(),
|
||||||
|
"msh_type21_flags_top": Counter(),
|
||||||
|
"map_logic_flags": Counter(),
|
||||||
|
"map_class_ids": Counter(), # record +40
|
||||||
|
"map_poly_count": Counter(),
|
||||||
|
"map_vertex_count_min": None,
|
||||||
|
"map_vertex_count_max": None,
|
||||||
|
"map_cell_dims": Counter(),
|
||||||
|
"map_reserved_u12": Counter(),
|
||||||
|
"map_reserved_u36": Counter(),
|
||||||
|
"map_reserved_u44": Counter(),
|
||||||
|
"map_area_delta_abs_max": 0.0,
|
||||||
|
"map_area_delta_rel_max": 0.0,
|
||||||
|
"map_area_rel_gt_05_count": 0,
|
||||||
|
"map_normal_len_min": None,
|
||||||
|
"map_normal_len_max": None,
|
||||||
|
"map_records_total": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
def add_issue(self, severity: str, category: str, resource: Path, message: str) -> None:
|
||||||
|
self.issues.append(
|
||||||
|
ValidationIssue(
|
||||||
|
severity=severity,
|
||||||
|
category=category,
|
||||||
|
resource=str(resource),
|
||||||
|
message=message,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _entry_payload(self, blob: bytes, entry: dict[str, Any]) -> bytes:
|
||||||
|
start = int(entry["data_offset"])
|
||||||
|
end = start + int(entry["size"])
|
||||||
|
return blob[start:end]
|
||||||
|
|
||||||
|
def _entry_by_type(self, entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
|
||||||
|
by_type: dict[int, list[dict[str, Any]]] = {}
|
||||||
|
for item in entries:
|
||||||
|
by_type.setdefault(int(item["type_id"]), []).append(item)
|
||||||
|
return by_type
|
||||||
|
|
||||||
|
def _expect_single_type(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
by_type: dict[int, list[dict[str, Any]]],
|
||||||
|
type_id: int,
|
||||||
|
label: str,
|
||||||
|
resource: Path,
|
||||||
|
required: bool,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
rows = by_type.get(type_id, [])
|
||||||
|
if not rows:
|
||||||
|
if required:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-chunk",
|
||||||
|
resource,
|
||||||
|
f"missing required chunk type={type_id} ({label})",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if len(rows) > 1:
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"msh-chunk",
|
||||||
|
resource,
|
||||||
|
f"multiple chunks type={type_id} ({label}); using first",
|
||||||
|
)
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
def _check_stride(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
resource: Path,
|
||||||
|
entry: dict[str, Any],
|
||||||
|
stride: int,
|
||||||
|
label: str,
|
||||||
|
) -> int:
|
||||||
|
size = int(entry["size"])
|
||||||
|
attr1 = int(entry["attr1"])
|
||||||
|
attr2 = int(entry["attr2"])
|
||||||
|
attr3 = int(entry["attr3"])
|
||||||
|
self.stats["msh_attr_triplets"][int(entry["type_id"])][(attr1, attr2, attr3)] += 1
|
||||||
|
|
||||||
|
if size % stride != 0:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-stride",
|
||||||
|
resource,
|
||||||
|
f"{label}: size={size} is not divisible by stride={stride}",
|
||||||
|
)
|
||||||
|
return -1
|
||||||
|
|
||||||
|
count = size // stride
|
||||||
|
if attr1 != count:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-attr",
|
||||||
|
resource,
|
||||||
|
f"{label}: attr1={attr1} != size/stride={count}",
|
||||||
|
)
|
||||||
|
if attr3 != stride:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-attr",
|
||||||
|
resource,
|
||||||
|
f"{label}: attr3={attr3} != {stride}",
|
||||||
|
)
|
||||||
|
if attr2 != 0 and int(entry["type_id"]) not in (1,):
|
||||||
|
# type 1 has non-zero attr2 in real assets, others are expected zero.
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"msh-attr",
|
||||||
|
resource,
|
||||||
|
f"{label}: attr2={attr2} (expected 0 for this chunk type)",
|
||||||
|
)
|
||||||
|
return count
|
||||||
|
|
||||||
|
def validate_msh(self, path: Path) -> None:
|
||||||
|
self.stats["msh_total"] += 1
|
||||||
|
blob = path.read_bytes()
|
||||||
|
if blob[:4] != MAGIC_NRES:
|
||||||
|
self.add_issue("error", "msh-container", path, "file is not NRes")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = arv.parse_nres(blob, source=str(path))
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
self.add_issue("error", "msh-container", path, f"failed to parse NRes: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
for issue in parsed.get("issues", []):
|
||||||
|
self.add_issue("warning", "msh-nres", path, issue)
|
||||||
|
|
||||||
|
entries = parsed["entries"]
|
||||||
|
types_order = tuple(int(item["type_id"]) for item in entries)
|
||||||
|
self.stats["msh_type_orders"][types_order] += 1
|
||||||
|
if types_order != EXPECTED_MSH_ORDER:
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"msh-order",
|
||||||
|
path,
|
||||||
|
f"unexpected chunk order {types_order}, expected {EXPECTED_MSH_ORDER}",
|
||||||
|
)
|
||||||
|
|
||||||
|
by_type = self._entry_by_type(entries)
|
||||||
|
|
||||||
|
chunks: dict[int, dict[str, Any]] = {}
|
||||||
|
for type_id in REQUIRED_MSH_TYPES:
|
||||||
|
chunk = self._expect_single_type(
|
||||||
|
by_type=by_type,
|
||||||
|
type_id=type_id,
|
||||||
|
label=f"type{type_id}",
|
||||||
|
resource=path,
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
if chunk:
|
||||||
|
chunks[type_id] = chunk
|
||||||
|
for type_id in OPTIONAL_MSH_TYPES:
|
||||||
|
chunk = self._expect_single_type(
|
||||||
|
by_type=by_type,
|
||||||
|
type_id=type_id,
|
||||||
|
label=f"type{type_id}",
|
||||||
|
resource=path,
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
if chunk:
|
||||||
|
chunks[type_id] = chunk
|
||||||
|
|
||||||
|
for type_id, stride in MSH_STRIDES.items():
|
||||||
|
chunk = chunks.get(type_id)
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
self._check_stride(resource=path, entry=chunk, stride=stride, label=f"type{type_id}")
|
||||||
|
|
||||||
|
# type 2 includes 0x8C-byte header + 68-byte slot table entries.
|
||||||
|
type2 = chunks.get(2)
|
||||||
|
if type2:
|
||||||
|
size = int(type2["size"])
|
||||||
|
attr1 = int(type2["attr1"])
|
||||||
|
attr2 = int(type2["attr2"])
|
||||||
|
attr3 = int(type2["attr3"])
|
||||||
|
self.stats["msh_attr_triplets"][2][(attr1, attr2, attr3)] += 1
|
||||||
|
if attr3 != 68:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-attr",
|
||||||
|
path,
|
||||||
|
f"type2: attr3={attr3} != 68",
|
||||||
|
)
|
||||||
|
if attr2 != 0:
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"msh-attr",
|
||||||
|
path,
|
||||||
|
f"type2: attr2={attr2} (expected 0)",
|
||||||
|
)
|
||||||
|
if size < SLOT_TABLE_OFFSET:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-size",
|
||||||
|
path,
|
||||||
|
f"type2: size={size} < header_size={SLOT_TABLE_OFFSET}",
|
||||||
|
)
|
||||||
|
elif (size - SLOT_TABLE_OFFSET) % 68 != 0:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-size",
|
||||||
|
path,
|
||||||
|
f"type2: (size - 0x8C) is not divisible by 68 (size={size})",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
slots_by_size = (size - SLOT_TABLE_OFFSET) // 68
|
||||||
|
if attr1 != slots_by_size:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-attr",
|
||||||
|
path,
|
||||||
|
f"type2: attr1={attr1} != (size-0x8C)/68={slots_by_size}",
|
||||||
|
)
|
||||||
|
|
||||||
|
verts = chunks.get(3)
|
||||||
|
face = chunks.get(21)
|
||||||
|
slots = chunks.get(2)
|
||||||
|
nodes = chunks.get(1)
|
||||||
|
type11 = chunks.get(11)
|
||||||
|
|
||||||
|
if verts and face:
|
||||||
|
vcount = int(verts["attr1"])
|
||||||
|
face_payload = self._entry_payload(blob, face)
|
||||||
|
fcount = int(face["attr1"])
|
||||||
|
if len(face_payload) >= 28:
|
||||||
|
for idx in range(fcount):
|
||||||
|
off = idx * 28
|
||||||
|
if off + 28 > len(face_payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-face",
|
||||||
|
path,
|
||||||
|
f"type21 truncated at face {idx}",
|
||||||
|
)
|
||||||
|
break
|
||||||
|
flags = struct.unpack_from("<I", face_payload, off)[0]
|
||||||
|
self.stats["msh_type21_flags_top"][flags] += 1
|
||||||
|
i0, i1, i2 = struct.unpack_from("<HHH", face_payload, off + 8)
|
||||||
|
for name, value in (("i0", i0), ("i1", i1), ("i2", i2)):
|
||||||
|
if value >= vcount:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-face-index",
|
||||||
|
path,
|
||||||
|
f"type21[{idx}].{name}={value} out of range vertex_count={vcount}",
|
||||||
|
)
|
||||||
|
n0, n1, n2 = struct.unpack_from("<HHH", face_payload, off + 14)
|
||||||
|
for name, value in (("n0", n0), ("n1", n1), ("n2", n2)):
|
||||||
|
if value != 0xFFFF and value >= fcount:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-face-neighbour",
|
||||||
|
path,
|
||||||
|
f"type21[{idx}].{name}={value} out of range face_count={fcount}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if slots and face:
|
||||||
|
slot_count = int(slots["attr1"])
|
||||||
|
face_count = int(face["attr1"])
|
||||||
|
slot_payload = self._entry_payload(blob, slots)
|
||||||
|
need = SLOT_TABLE_OFFSET + slot_count * 68
|
||||||
|
if len(slot_payload) < need:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-slot",
|
||||||
|
path,
|
||||||
|
f"type2 payload too short: size={len(slot_payload)}, need_at_least={need}",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if len(slot_payload) != need:
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"msh-slot",
|
||||||
|
path,
|
||||||
|
f"type2 payload has trailing bytes: size={len(slot_payload)}, expected={need}",
|
||||||
|
)
|
||||||
|
for idx in range(slot_count):
|
||||||
|
off = SLOT_TABLE_OFFSET + idx * 68
|
||||||
|
tri_start, tri_count = struct.unpack_from("<HH", slot_payload, off)
|
||||||
|
if tri_start + tri_count > face_count:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-slot-range",
|
||||||
|
path,
|
||||||
|
f"type2 slot[{idx}] range [{tri_start}, {tri_start + tri_count}) exceeds face_count={face_count}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if nodes and slots:
|
||||||
|
node_payload = self._entry_payload(blob, nodes)
|
||||||
|
slot_count = int(slots["attr1"])
|
||||||
|
node_count = int(nodes["attr1"])
|
||||||
|
for node_idx in range(node_count):
|
||||||
|
off = node_idx * 38
|
||||||
|
if off + 38 > len(node_payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-node",
|
||||||
|
path,
|
||||||
|
f"type1 truncated at node {node_idx}",
|
||||||
|
)
|
||||||
|
break
|
||||||
|
for j in range(19):
|
||||||
|
slot_id = struct.unpack_from("<H", node_payload, off + j * 2)[0]
|
||||||
|
if slot_id != 0xFFFF and slot_id >= slot_count:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-node-slot",
|
||||||
|
path,
|
||||||
|
f"type1 node[{node_idx}] slot[{j}]={slot_id} out of range slot_count={slot_count}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if type11:
|
||||||
|
payload = self._entry_payload(blob, type11)
|
||||||
|
if len(payload) >= 8:
|
||||||
|
w0, w1 = struct.unpack_from("<II", payload, 0)
|
||||||
|
self.stats["msh_type11_header_words"][(w0, w1)] += 1
|
||||||
|
else:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"msh-type11",
|
||||||
|
path,
|
||||||
|
f"type11 payload too short: {len(payload)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _update_minmax(self, key_min: str, key_max: str, value: float) -> None:
|
||||||
|
if self.stats[key_min] is None or value < self.stats[key_min]:
|
||||||
|
self.stats[key_min] = value
|
||||||
|
if self.stats[key_max] is None or value > self.stats[key_max]:
|
||||||
|
self.stats[key_max] = value
|
||||||
|
|
||||||
|
def validate_map(self, path: Path) -> None:
|
||||||
|
self.stats["map_total"] += 1
|
||||||
|
blob = path.read_bytes()
|
||||||
|
if blob[:4] != MAGIC_NRES:
|
||||||
|
self.add_issue("error", "map-container", path, "file is not NRes")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = arv.parse_nres(blob, source=str(path))
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
self.add_issue("error", "map-container", path, f"failed to parse NRes: {exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
for issue in parsed.get("issues", []):
|
||||||
|
self.add_issue("warning", "map-nres", path, issue)
|
||||||
|
|
||||||
|
entries = parsed["entries"]
|
||||||
|
if len(entries) != 1 or int(entries[0]["type_id"]) != 12:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-chunk",
|
||||||
|
path,
|
||||||
|
f"expected single chunk type=12, got {[int(e['type_id']) for e in entries]}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
entry = entries[0]
|
||||||
|
areal_count = int(entry["attr1"])
|
||||||
|
if areal_count <= 0:
|
||||||
|
self.add_issue("error", "map-areal", path, f"invalid areal_count={areal_count}")
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = self._entry_payload(blob, entry)
|
||||||
|
ptr = 0
|
||||||
|
records: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for idx in range(areal_count):
|
||||||
|
if ptr + 56 > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-record",
|
||||||
|
path,
|
||||||
|
f"truncated areal header at index={idx}, ptr={ptr}, size={len(payload)}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
anchor_x, anchor_y, anchor_z = struct.unpack_from("<fff", payload, ptr)
|
||||||
|
u12 = struct.unpack_from("<I", payload, ptr + 12)[0]
|
||||||
|
area_f = struct.unpack_from("<f", payload, ptr + 16)[0]
|
||||||
|
nx, ny, nz = struct.unpack_from("<fff", payload, ptr + 20)
|
||||||
|
logic_flag = struct.unpack_from("<I", payload, ptr + 32)[0]
|
||||||
|
u36 = struct.unpack_from("<I", payload, ptr + 36)[0]
|
||||||
|
class_id = struct.unpack_from("<I", payload, ptr + 40)[0]
|
||||||
|
u44 = struct.unpack_from("<I", payload, ptr + 44)[0]
|
||||||
|
vertex_count, poly_count = struct.unpack_from("<II", payload, ptr + 48)
|
||||||
|
|
||||||
|
self.stats["map_records_total"] += 1
|
||||||
|
self.stats["map_logic_flags"][logic_flag] += 1
|
||||||
|
self.stats["map_class_ids"][class_id] += 1
|
||||||
|
self.stats["map_poly_count"][poly_count] += 1
|
||||||
|
self.stats["map_reserved_u12"][u12] += 1
|
||||||
|
self.stats["map_reserved_u36"][u36] += 1
|
||||||
|
self.stats["map_reserved_u44"][u44] += 1
|
||||||
|
self._update_minmax("map_vertex_count_min", "map_vertex_count_max", float(vertex_count))
|
||||||
|
|
||||||
|
normal_len = math.sqrt(nx * nx + ny * ny + nz * nz)
|
||||||
|
self._update_minmax("map_normal_len_min", "map_normal_len_max", normal_len)
|
||||||
|
if abs(normal_len - 1.0) > 1e-3:
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"map-normal",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] normal length={normal_len:.6f} (expected ~1.0)",
|
||||||
|
)
|
||||||
|
|
||||||
|
vertices_off = ptr + 56
|
||||||
|
vertices_size = 12 * vertex_count
|
||||||
|
if vertices_off + vertices_size > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-vertices",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] vertices out of bounds",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
vertices: list[tuple[float, float, float]] = []
|
||||||
|
for i in range(vertex_count):
|
||||||
|
vertices.append(struct.unpack_from("<fff", payload, vertices_off + i * 12))
|
||||||
|
|
||||||
|
if vertex_count >= 3:
|
||||||
|
# signed shoelace area in XY.
|
||||||
|
shoelace = 0.0
|
||||||
|
for i in range(vertex_count):
|
||||||
|
x1, y1, _ = vertices[i]
|
||||||
|
x2, y2, _ = vertices[(i + 1) % vertex_count]
|
||||||
|
shoelace += x1 * y2 - x2 * y1
|
||||||
|
area_xy = abs(shoelace) * 0.5
|
||||||
|
delta = abs(area_xy - area_f)
|
||||||
|
if delta > self.stats["map_area_delta_abs_max"]:
|
||||||
|
self.stats["map_area_delta_abs_max"] = delta
|
||||||
|
rel_delta = delta / max(1.0, area_xy)
|
||||||
|
if rel_delta > self.stats["map_area_delta_rel_max"]:
|
||||||
|
self.stats["map_area_delta_rel_max"] = rel_delta
|
||||||
|
if rel_delta > 0.05:
|
||||||
|
self.stats["map_area_rel_gt_05_count"] += 1
|
||||||
|
|
||||||
|
links_off = vertices_off + vertices_size
|
||||||
|
link_count = vertex_count + 3 * poly_count
|
||||||
|
links_size = 8 * link_count
|
||||||
|
if links_off + links_size > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-links",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] link table out of bounds",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
edge_links: list[tuple[int, int]] = []
|
||||||
|
for i in range(vertex_count):
|
||||||
|
area_ref, edge_ref = struct.unpack_from("<ii", payload, links_off + i * 8)
|
||||||
|
edge_links.append((area_ref, edge_ref))
|
||||||
|
|
||||||
|
poly_links_off = links_off + 8 * vertex_count
|
||||||
|
poly_links: list[tuple[int, int]] = []
|
||||||
|
for i in range(3 * poly_count):
|
||||||
|
area_ref, edge_ref = struct.unpack_from("<ii", payload, poly_links_off + i * 8)
|
||||||
|
poly_links.append((area_ref, edge_ref))
|
||||||
|
|
||||||
|
p = links_off + links_size
|
||||||
|
for poly_idx in range(poly_count):
|
||||||
|
if p + 4 > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-poly",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] poly header truncated at poly_idx={poly_idx}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
n = struct.unpack_from("<I", payload, p)[0]
|
||||||
|
poly_size = 4 * (3 * n + 1)
|
||||||
|
if p + poly_size > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-poly",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] poly data out of bounds at poly_idx={poly_idx}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
p += poly_size
|
||||||
|
|
||||||
|
records.append(
|
||||||
|
{
|
||||||
|
"index": idx,
|
||||||
|
"anchor": (anchor_x, anchor_y, anchor_z),
|
||||||
|
"logic": logic_flag,
|
||||||
|
"class_id": class_id,
|
||||||
|
"vertex_count": vertex_count,
|
||||||
|
"poly_count": poly_count,
|
||||||
|
"edge_links": edge_links,
|
||||||
|
"poly_links": poly_links,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ptr = p
|
||||||
|
|
||||||
|
vertex_counts = [int(item["vertex_count"]) for item in records]
|
||||||
|
for rec in records:
|
||||||
|
idx = int(rec["index"])
|
||||||
|
for link_idx, (area_ref, edge_ref) in enumerate(rec["edge_links"]):
|
||||||
|
if area_ref == -1:
|
||||||
|
if edge_ref != -1:
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"map-link",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] edge_link[{link_idx}] has area_ref=-1 but edge_ref={edge_ref}",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if area_ref < 0 or area_ref >= areal_count:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-link",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] edge_link[{link_idx}] area_ref={area_ref} out of range",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
dst_vcount = vertex_counts[area_ref]
|
||||||
|
if edge_ref < 0 or edge_ref >= dst_vcount:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-link",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] edge_link[{link_idx}] edge_ref={edge_ref} out of range dst_vertex_count={dst_vcount}",
|
||||||
|
)
|
||||||
|
|
||||||
|
for link_idx, (area_ref, edge_ref) in enumerate(rec["poly_links"]):
|
||||||
|
if area_ref == -1:
|
||||||
|
if edge_ref != -1:
|
||||||
|
self.add_issue(
|
||||||
|
"warning",
|
||||||
|
"map-poly-link",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] poly_link[{link_idx}] has area_ref=-1 but edge_ref={edge_ref}",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if area_ref < 0 or area_ref >= areal_count:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-poly-link",
|
||||||
|
path,
|
||||||
|
f"record[{idx}] poly_link[{link_idx}] area_ref={area_ref} out of range",
|
||||||
|
)
|
||||||
|
|
||||||
|
if ptr + 8 > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-cells",
|
||||||
|
path,
|
||||||
|
f"missing cells header at ptr={ptr}, size={len(payload)}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
cells_x, cells_y = struct.unpack_from("<II", payload, ptr)
|
||||||
|
self.stats["map_cell_dims"][(cells_x, cells_y)] += 1
|
||||||
|
ptr += 8
|
||||||
|
if cells_x <= 0 or cells_y <= 0:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-cells",
|
||||||
|
path,
|
||||||
|
f"invalid cells dimensions {cells_x}x{cells_y}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for x in range(cells_x):
|
||||||
|
for y in range(cells_y):
|
||||||
|
if ptr + 2 > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-cells",
|
||||||
|
path,
|
||||||
|
f"truncated hitCount at cell ({x},{y})",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
hit_count = struct.unpack_from("<H", payload, ptr)[0]
|
||||||
|
ptr += 2
|
||||||
|
need = 2 * hit_count
|
||||||
|
if ptr + need > len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-cells",
|
||||||
|
path,
|
||||||
|
f"truncated areaIds at cell ({x},{y}), hitCount={hit_count}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
for i in range(hit_count):
|
||||||
|
area_id = struct.unpack_from("<H", payload, ptr + 2 * i)[0]
|
||||||
|
if area_id >= areal_count:
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-cells",
|
||||||
|
path,
|
||||||
|
f"cell ({x},{y}) has area_id={area_id} out of range areal_count={areal_count}",
|
||||||
|
)
|
||||||
|
ptr += need
|
||||||
|
|
||||||
|
if ptr != len(payload):
|
||||||
|
self.add_issue(
|
||||||
|
"error",
|
||||||
|
"map-size",
|
||||||
|
path,
|
||||||
|
f"payload tail mismatch: consumed={ptr}, payload_size={len(payload)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def validate(self, maps_root: Path) -> None:
|
||||||
|
msh_paths = sorted(maps_root.rglob("Land.msh"))
|
||||||
|
map_paths = sorted(maps_root.rglob("Land.map"))
|
||||||
|
|
||||||
|
msh_by_dir = {path.parent: path for path in msh_paths}
|
||||||
|
map_by_dir = {path.parent: path for path in map_paths}
|
||||||
|
|
||||||
|
all_dirs = sorted(set(msh_by_dir) | set(map_by_dir))
|
||||||
|
self.stats["maps_total"] = len(all_dirs)
|
||||||
|
|
||||||
|
for folder in all_dirs:
|
||||||
|
msh_path = msh_by_dir.get(folder)
|
||||||
|
map_path = map_by_dir.get(folder)
|
||||||
|
if msh_path is None:
|
||||||
|
self.add_issue("error", "pairing", folder, "missing Land.msh")
|
||||||
|
continue
|
||||||
|
if map_path is None:
|
||||||
|
self.add_issue("error", "pairing", folder, "missing Land.map")
|
||||||
|
continue
|
||||||
|
self.validate_msh(msh_path)
|
||||||
|
self.validate_map(map_path)
|
||||||
|
|
||||||
|
def build_report(self) -> dict[str, Any]:
|
||||||
|
errors = [i for i in self.issues if i.severity == "error"]
|
||||||
|
warnings = [i for i in self.issues if i.severity == "warning"]
|
||||||
|
|
||||||
|
# Convert counters/defaultdicts to JSON-friendly dicts.
|
||||||
|
msh_orders = {
|
||||||
|
str(list(order)): count
|
||||||
|
for order, count in self.stats["msh_type_orders"].most_common()
|
||||||
|
}
|
||||||
|
msh_attrs = {
|
||||||
|
str(type_id): {str(list(k)): v for k, v in counter.most_common()}
|
||||||
|
for type_id, counter in self.stats["msh_attr_triplets"].items()
|
||||||
|
}
|
||||||
|
type11_hdr = {
|
||||||
|
str(list(key)): value
|
||||||
|
for key, value in self.stats["msh_type11_header_words"].most_common()
|
||||||
|
}
|
||||||
|
type21_flags = {
|
||||||
|
f"0x{key:08X}": value
|
||||||
|
for key, value in self.stats["msh_type21_flags_top"].most_common(32)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": {
|
||||||
|
"maps_total": self.stats["maps_total"],
|
||||||
|
"msh_total": self.stats["msh_total"],
|
||||||
|
"map_total": self.stats["map_total"],
|
||||||
|
"issues_total": len(self.issues),
|
||||||
|
"errors_total": len(errors),
|
||||||
|
"warnings_total": len(warnings),
|
||||||
|
},
|
||||||
|
"stats": {
|
||||||
|
"msh_type_orders": msh_orders,
|
||||||
|
"msh_attr_triplets": msh_attrs,
|
||||||
|
"msh_type11_header_words": type11_hdr,
|
||||||
|
"msh_type21_flags_top": type21_flags,
|
||||||
|
"map_logic_flags": dict(self.stats["map_logic_flags"]),
|
||||||
|
"map_class_ids": dict(self.stats["map_class_ids"]),
|
||||||
|
"map_poly_count": dict(self.stats["map_poly_count"]),
|
||||||
|
"map_vertex_count_min": self.stats["map_vertex_count_min"],
|
||||||
|
"map_vertex_count_max": self.stats["map_vertex_count_max"],
|
||||||
|
"map_cell_dims": {str(list(k)): v for k, v in self.stats["map_cell_dims"].items()},
|
||||||
|
"map_reserved_u12": dict(self.stats["map_reserved_u12"]),
|
||||||
|
"map_reserved_u36": dict(self.stats["map_reserved_u36"]),
|
||||||
|
"map_reserved_u44": dict(self.stats["map_reserved_u44"]),
|
||||||
|
"map_area_delta_abs_max": self.stats["map_area_delta_abs_max"],
|
||||||
|
"map_area_delta_rel_max": self.stats["map_area_delta_rel_max"],
|
||||||
|
"map_area_rel_gt_05_count": self.stats["map_area_rel_gt_05_count"],
|
||||||
|
"map_normal_len_min": self.stats["map_normal_len_min"],
|
||||||
|
"map_normal_len_max": self.stats["map_normal_len_max"],
|
||||||
|
"map_records_total": self.stats["map_records_total"],
|
||||||
|
},
|
||||||
|
"issues": [
|
||||||
|
{
|
||||||
|
"severity": item.severity,
|
||||||
|
"category": item.category,
|
||||||
|
"resource": item.resource,
|
||||||
|
"message": item.message,
|
||||||
|
}
|
||||||
|
for item in self.issues
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args() -> argparse.Namespace:
|
||||||
|
parser = argparse.ArgumentParser(description="Validate terrain/map doc assumptions")
|
||||||
|
parser.add_argument(
|
||||||
|
"--maps-root",
|
||||||
|
type=Path,
|
||||||
|
default=Path("tmp/gamedata/DATA/MAPS"),
|
||||||
|
help="Root directory containing MAPS/**/Land.msh and Land.map",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--report-json",
|
||||||
|
type=Path,
|
||||||
|
default=None,
|
||||||
|
help="Optional path to save full JSON report",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--fail-on-warning",
|
||||||
|
action="store_true",
|
||||||
|
help="Return non-zero exit code on warnings too",
|
||||||
|
)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
args = parse_args()
|
||||||
|
validator = TerrainMapDocValidator()
|
||||||
|
validator.validate(args.maps_root)
|
||||||
|
report = validator.build_report()
|
||||||
|
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
report["summary"],
|
||||||
|
indent=2,
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.report_json:
|
||||||
|
args.report_json.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with args.report_json.open("w", encoding="utf-8") as handle:
|
||||||
|
json.dump(report, handle, indent=2, ensure_ascii=False)
|
||||||
|
handle.write("\n")
|
||||||
|
print(f"report written: {args.report_json}")
|
||||||
|
|
||||||
|
has_errors = report["summary"]["errors_total"] > 0
|
||||||
|
has_warnings = report["summary"]["warnings_total"] > 0
|
||||||
|
if has_errors:
|
||||||
|
return 1
|
||||||
|
if args.fail_on_warning and has_warnings:
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
679
tools/terrain_map_preview_renderer.py
Normal file
679
tools/terrain_map_preview_renderer.py
Normal file
@@ -0,0 +1,679 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Software 3D renderer for terrain Land.msh + Land.map overlay.
|
||||||
|
|
||||||
|
Output format: binary PPM (P6), dependency-free.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import archive_roundtrip_validator as arv
|
||||||
|
|
||||||
|
MAGIC_NRES = b"NRes"
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_payload(blob: bytes, entry: dict[str, Any]) -> bytes:
|
||||||
|
start = int(entry["data_offset"])
|
||||||
|
end = start + int(entry["size"])
|
||||||
|
return blob[start:end]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_nres(blob: bytes, source: str) -> dict[str, Any]:
|
||||||
|
if blob[:4] != MAGIC_NRES:
|
||||||
|
raise RuntimeError(f"{source}: not an NRes payload")
|
||||||
|
return arv.parse_nres(blob, source=source)
|
||||||
|
|
||||||
|
|
||||||
|
def _by_type(entries: list[dict[str, Any]]) -> dict[int, list[dict[str, Any]]]:
|
||||||
|
out: dict[int, list[dict[str, Any]]] = {}
|
||||||
|
for row in entries:
|
||||||
|
out.setdefault(int(row["type_id"]), []).append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _get_single(by_type: dict[int, list[dict[str, Any]]], type_id: int, label: str) -> dict[str, Any]:
|
||||||
|
rows = by_type.get(type_id, [])
|
||||||
|
if not rows:
|
||||||
|
raise RuntimeError(f"missing resource type {type_id} ({label})")
|
||||||
|
return rows[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _downsample_faces(
|
||||||
|
faces: list[tuple[int, int, int]],
|
||||||
|
max_faces: int,
|
||||||
|
) -> list[tuple[int, int, int]]:
|
||||||
|
if max_faces <= 0 or len(faces) <= max_faces:
|
||||||
|
return faces
|
||||||
|
step = len(faces) / max_faces
|
||||||
|
out: list[tuple[int, int, int]] = []
|
||||||
|
pos = 0.0
|
||||||
|
while len(out) < max_faces and int(pos) < len(faces):
|
||||||
|
out.append(faces[int(pos)])
|
||||||
|
pos += step
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def load_terrain_msh(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
max_faces: int,
|
||||||
|
) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]], dict[str, int]]:
|
||||||
|
blob = path.read_bytes()
|
||||||
|
parsed = _parse_nres(blob, str(path))
|
||||||
|
by_type = _by_type(parsed["entries"])
|
||||||
|
|
||||||
|
res3 = _get_single(by_type, 3, "positions")
|
||||||
|
res21 = _get_single(by_type, 21, "terrain faces")
|
||||||
|
|
||||||
|
pos_blob = _entry_payload(blob, res3)
|
||||||
|
if len(pos_blob) % 12 != 0:
|
||||||
|
raise RuntimeError(f"{path}: type 3 payload size is not divisible by 12")
|
||||||
|
vertex_count = len(pos_blob) // 12
|
||||||
|
positions = [struct.unpack_from("<3f", pos_blob, i * 12) for i in range(vertex_count)]
|
||||||
|
|
||||||
|
face_blob = _entry_payload(blob, res21)
|
||||||
|
if len(face_blob) % 28 != 0:
|
||||||
|
raise RuntimeError(f"{path}: type 21 payload size is not divisible by 28")
|
||||||
|
all_faces: list[tuple[int, int, int]] = []
|
||||||
|
raw_face_count = len(face_blob) // 28
|
||||||
|
dropped = 0
|
||||||
|
for i in range(raw_face_count):
|
||||||
|
off = i * 28
|
||||||
|
i0, i1, i2 = struct.unpack_from("<HHH", face_blob, off + 8)
|
||||||
|
if i0 >= vertex_count or i1 >= vertex_count or i2 >= vertex_count:
|
||||||
|
dropped += 1
|
||||||
|
continue
|
||||||
|
all_faces.append((i0, i1, i2))
|
||||||
|
|
||||||
|
faces = _downsample_faces(all_faces, max_faces)
|
||||||
|
meta = {
|
||||||
|
"vertex_count": vertex_count,
|
||||||
|
"face_count_raw": raw_face_count,
|
||||||
|
"face_count_valid": len(all_faces),
|
||||||
|
"face_count_rendered": len(faces),
|
||||||
|
"face_dropped_invalid": dropped,
|
||||||
|
}
|
||||||
|
return positions, faces, meta
|
||||||
|
|
||||||
|
|
||||||
|
def load_areal_map(path: Path) -> tuple[list[dict[str, Any]], dict[str, int]]:
|
||||||
|
blob = path.read_bytes()
|
||||||
|
parsed = _parse_nres(blob, str(path))
|
||||||
|
by_type = _by_type(parsed["entries"])
|
||||||
|
chunk = _get_single(by_type, 12, "ArealMapGeometry")
|
||||||
|
|
||||||
|
payload = _entry_payload(blob, chunk)
|
||||||
|
areal_count = int(chunk["attr1"])
|
||||||
|
ptr = 0
|
||||||
|
areals: list[dict[str, Any]] = []
|
||||||
|
for idx in range(areal_count):
|
||||||
|
if ptr + 56 > len(payload):
|
||||||
|
raise RuntimeError(f"{path}: truncated areal header at index={idx}")
|
||||||
|
class_id = struct.unpack_from("<I", payload, ptr + 40)[0]
|
||||||
|
vertex_count, poly_count = struct.unpack_from("<II", payload, ptr + 48)
|
||||||
|
verts_off = ptr + 56
|
||||||
|
verts_size = 12 * vertex_count
|
||||||
|
if verts_off + verts_size > len(payload):
|
||||||
|
raise RuntimeError(f"{path}: areal[{idx}] vertices out of bounds")
|
||||||
|
verts = [struct.unpack_from("<3f", payload, verts_off + 12 * i) for i in range(vertex_count)]
|
||||||
|
|
||||||
|
links_off = verts_off + verts_size
|
||||||
|
links_size = 8 * (vertex_count + 3 * poly_count)
|
||||||
|
p = links_off + links_size
|
||||||
|
for _ in range(poly_count):
|
||||||
|
if p + 4 > len(payload):
|
||||||
|
raise RuntimeError(f"{path}: areal[{idx}] poly header out of bounds")
|
||||||
|
n = struct.unpack_from("<I", payload, p)[0]
|
||||||
|
p += 4 * (3 * n + 1)
|
||||||
|
if p > len(payload):
|
||||||
|
raise RuntimeError(f"{path}: areal[{idx}] poly data out of bounds")
|
||||||
|
|
||||||
|
areals.append(
|
||||||
|
{
|
||||||
|
"index": idx,
|
||||||
|
"class_id": class_id,
|
||||||
|
"vertices": verts,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
ptr = p
|
||||||
|
|
||||||
|
if ptr + 8 > len(payload):
|
||||||
|
raise RuntimeError(f"{path}: missing cells section")
|
||||||
|
cells_x, cells_y = struct.unpack_from("<II", payload, ptr)
|
||||||
|
ptr += 8
|
||||||
|
for _x in range(cells_x):
|
||||||
|
for _y in range(cells_y):
|
||||||
|
if ptr + 2 > len(payload):
|
||||||
|
raise RuntimeError(f"{path}: cells section truncated")
|
||||||
|
hit_count = struct.unpack_from("<H", payload, ptr)[0]
|
||||||
|
ptr += 2 + 2 * hit_count
|
||||||
|
if ptr > len(payload):
|
||||||
|
raise RuntimeError(f"{path}: cells section out of bounds")
|
||||||
|
if ptr != len(payload):
|
||||||
|
raise RuntimeError(f"{path}: trailing bytes in chunk12 parse ({len(payload) - ptr})")
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
"areal_count": areal_count,
|
||||||
|
"cells_x": cells_x,
|
||||||
|
"cells_y": cells_y,
|
||||||
|
}
|
||||||
|
return areals, meta
|
||||||
|
|
||||||
|
|
||||||
|
def _color_for_class(class_id: int) -> tuple[int, int, int]:
|
||||||
|
x = (class_id * 1103515245 + 12345) & 0x7FFFFFFF
|
||||||
|
r = 60 + (x & 0x7F)
|
||||||
|
g = 60 + ((x >> 7) & 0x7F)
|
||||||
|
b = 60 + ((x >> 14) & 0x7F)
|
||||||
|
return r, g, b
|
||||||
|
|
||||||
|
|
||||||
|
def _write_ppm(path: Path, width: int, height: int, rgb: bytearray) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("wb") as handle:
|
||||||
|
handle.write(f"P6\n{width} {height}\n255\n".encode("ascii"))
|
||||||
|
handle.write(rgb)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_obj(
|
||||||
|
path: Path,
|
||||||
|
terrain_positions: list[tuple[float, float, float]],
|
||||||
|
terrain_faces: list[tuple[int, int, int]],
|
||||||
|
areals: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
include_areals: bool,
|
||||||
|
) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with path.open("w", encoding="utf-8", newline="\n") as out:
|
||||||
|
out.write("# Exported by terrain_map_preview_renderer.py\n")
|
||||||
|
out.write("o terrain\n")
|
||||||
|
for x, y, z in terrain_positions:
|
||||||
|
out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n")
|
||||||
|
for i0, i1, i2 in terrain_faces:
|
||||||
|
# OBJ indices are 1-based.
|
||||||
|
out.write(f"f {i0 + 1} {i1 + 1} {i2 + 1}\n")
|
||||||
|
|
||||||
|
if include_areals and areals:
|
||||||
|
base = len(terrain_positions)
|
||||||
|
area_vertex_counts: list[int] = []
|
||||||
|
out.write("o areal_edges\n")
|
||||||
|
for area in areals:
|
||||||
|
verts = area["vertices"]
|
||||||
|
area_vertex_counts.append(len(verts))
|
||||||
|
for x, y, z in verts:
|
||||||
|
out.write(f"v {x:.9g} {y:.9g} {z:.9g}\n")
|
||||||
|
|
||||||
|
ptr = base
|
||||||
|
for area_idx, area in enumerate(areals):
|
||||||
|
cnt = area_vertex_counts[area_idx]
|
||||||
|
if cnt < 2:
|
||||||
|
ptr += cnt
|
||||||
|
continue
|
||||||
|
# closed polyline.
|
||||||
|
line = [str(ptr + i + 1) for i in range(cnt)]
|
||||||
|
line.append(str(ptr + 1))
|
||||||
|
out.write("l " + " ".join(line) + "\n")
|
||||||
|
ptr += cnt
|
||||||
|
|
||||||
|
|
||||||
|
def _render_scene(
|
||||||
|
terrain_positions: list[tuple[float, float, float]],
|
||||||
|
terrain_faces: list[tuple[int, int, int]],
|
||||||
|
areals: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
width: int,
|
||||||
|
height: int,
|
||||||
|
yaw_deg: float,
|
||||||
|
pitch_deg: float,
|
||||||
|
wireframe: bool,
|
||||||
|
areal_overlay: bool,
|
||||||
|
) -> bytearray:
|
||||||
|
all_positions = list(terrain_positions)
|
||||||
|
if areal_overlay:
|
||||||
|
for area in areals:
|
||||||
|
all_positions.extend(area["vertices"])
|
||||||
|
if not all_positions:
|
||||||
|
raise RuntimeError("scene is empty")
|
||||||
|
|
||||||
|
xs = [p[0] for p in all_positions]
|
||||||
|
ys = [p[1] for p in all_positions]
|
||||||
|
zs = [p[2] for p in all_positions]
|
||||||
|
cx = (min(xs) + max(xs)) * 0.5
|
||||||
|
cy = (min(ys) + max(ys)) * 0.5
|
||||||
|
cz = (min(zs) + max(zs)) * 0.5
|
||||||
|
span = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs))
|
||||||
|
radius = max(span * 0.5, 1e-3)
|
||||||
|
|
||||||
|
yaw = math.radians(yaw_deg)
|
||||||
|
pitch = math.radians(pitch_deg)
|
||||||
|
cyaw = math.cos(yaw)
|
||||||
|
syaw = math.sin(yaw)
|
||||||
|
cpitch = math.cos(pitch)
|
||||||
|
spitch = math.sin(pitch)
|
||||||
|
camera_dist = radius * 3.2
|
||||||
|
scale = min(width, height) * 0.96
|
||||||
|
|
||||||
|
# Terrain transform cache.
|
||||||
|
vx: list[float] = []
|
||||||
|
vy: list[float] = []
|
||||||
|
vz: list[float] = []
|
||||||
|
sx: list[float] = []
|
||||||
|
sy: list[float] = []
|
||||||
|
for x, y, z in terrain_positions:
|
||||||
|
x0 = x - cx
|
||||||
|
y0 = y - cy
|
||||||
|
z0 = z - cz
|
||||||
|
x1 = cyaw * x0 + syaw * z0
|
||||||
|
z1 = -syaw * x0 + cyaw * z0
|
||||||
|
y2 = cpitch * y0 - spitch * z1
|
||||||
|
z2 = spitch * y0 + cpitch * z1 + camera_dist
|
||||||
|
if z2 < 1e-3:
|
||||||
|
z2 = 1e-3
|
||||||
|
vx.append(x1)
|
||||||
|
vy.append(y2)
|
||||||
|
vz.append(z2)
|
||||||
|
sx.append(width * 0.5 + (x1 / z2) * scale)
|
||||||
|
sy.append(height * 0.5 - (y2 / z2) * scale)
|
||||||
|
|
||||||
|
def project_point(x: float, y: float, z: float) -> tuple[float, float, float]:
|
||||||
|
x0 = x - cx
|
||||||
|
y0 = y - cy
|
||||||
|
z0 = z - cz
|
||||||
|
x1 = cyaw * x0 + syaw * z0
|
||||||
|
z1 = -syaw * x0 + cyaw * z0
|
||||||
|
y2 = cpitch * y0 - spitch * z1
|
||||||
|
z2 = spitch * y0 + cpitch * z1 + camera_dist
|
||||||
|
if z2 < 1e-3:
|
||||||
|
z2 = 1e-3
|
||||||
|
px = width * 0.5 + (x1 / z2) * scale
|
||||||
|
py = height * 0.5 - (y2 / z2) * scale
|
||||||
|
return px, py, z2
|
||||||
|
|
||||||
|
rgb = bytearray([14, 16, 20] * (width * height))
|
||||||
|
zbuf = [float("inf")] * (width * height)
|
||||||
|
light_dir = (0.35, 0.45, 1.0)
|
||||||
|
l_len = math.sqrt(light_dir[0] ** 2 + light_dir[1] ** 2 + light_dir[2] ** 2)
|
||||||
|
light = (light_dir[0] / l_len, light_dir[1] / l_len, light_dir[2] / l_len)
|
||||||
|
|
||||||
|
def edge(ax: float, ay: float, bx: float, by: float, px: float, py: float) -> float:
|
||||||
|
return (px - ax) * (by - ay) - (py - ay) * (bx - ax)
|
||||||
|
|
||||||
|
for i0, i1, i2 in terrain_faces:
|
||||||
|
x0 = sx[i0]
|
||||||
|
y0 = sy[i0]
|
||||||
|
x1 = sx[i1]
|
||||||
|
y1 = sy[i1]
|
||||||
|
x2 = sx[i2]
|
||||||
|
y2 = sy[i2]
|
||||||
|
area = edge(x0, y0, x1, y1, x2, y2)
|
||||||
|
if area == 0.0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
ux = vx[i1] - vx[i0]
|
||||||
|
uy = vy[i1] - vy[i0]
|
||||||
|
uz = vz[i1] - vz[i0]
|
||||||
|
wx = vx[i2] - vx[i0]
|
||||||
|
wy = vy[i2] - vy[i0]
|
||||||
|
wz = vz[i2] - vz[i0]
|
||||||
|
nx = uy * wz - uz * wy
|
||||||
|
ny = uz * wx - ux * wz
|
||||||
|
nz = ux * wy - uy * wx
|
||||||
|
n_len = math.sqrt(nx * nx + ny * ny + nz * nz)
|
||||||
|
if n_len > 0.0:
|
||||||
|
nx /= n_len
|
||||||
|
ny /= n_len
|
||||||
|
nz /= n_len
|
||||||
|
intensity = nx * light[0] + ny * light[1] + nz * light[2]
|
||||||
|
if intensity < 0.0:
|
||||||
|
intensity = 0.0
|
||||||
|
shade = int(45 + 185 * intensity)
|
||||||
|
color = (min(255, shade + 6), min(255, shade + 14), min(255, shade + 28))
|
||||||
|
|
||||||
|
minx = int(max(0, math.floor(min(x0, x1, x2))))
|
||||||
|
maxx = int(min(width - 1, math.ceil(max(x0, x1, x2))))
|
||||||
|
miny = int(max(0, math.floor(min(y0, y1, y2))))
|
||||||
|
maxy = int(min(height - 1, math.ceil(max(y0, y1, y2))))
|
||||||
|
if minx > maxx or miny > maxy:
|
||||||
|
continue
|
||||||
|
|
||||||
|
z0 = vz[i0]
|
||||||
|
z1 = vz[i1]
|
||||||
|
z2 = vz[i2]
|
||||||
|
inv_area = 1.0 / area
|
||||||
|
for py in range(miny, maxy + 1):
|
||||||
|
fy = py + 0.5
|
||||||
|
row = py * width
|
||||||
|
for px in range(minx, maxx + 1):
|
||||||
|
fx = px + 0.5
|
||||||
|
w0 = edge(x1, y1, x2, y2, fx, fy)
|
||||||
|
w1 = edge(x2, y2, x0, y0, fx, fy)
|
||||||
|
w2 = edge(x0, y0, x1, y1, fx, fy)
|
||||||
|
if area > 0:
|
||||||
|
if w0 < 0 or w1 < 0 or w2 < 0:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if w0 > 0 or w1 > 0 or w2 > 0:
|
||||||
|
continue
|
||||||
|
bz0 = w0 * inv_area
|
||||||
|
bz1 = w1 * inv_area
|
||||||
|
bz2 = w2 * inv_area
|
||||||
|
depth = bz0 * z0 + bz1 * z1 + bz2 * z2
|
||||||
|
idx = row + px
|
||||||
|
if depth >= zbuf[idx]:
|
||||||
|
continue
|
||||||
|
zbuf[idx] = depth
|
||||||
|
p = idx * 3
|
||||||
|
rgb[p + 0] = color[0]
|
||||||
|
rgb[p + 1] = color[1]
|
||||||
|
rgb[p + 2] = color[2]
|
||||||
|
|
||||||
|
def draw_line(
|
||||||
|
xa: float,
|
||||||
|
ya: float,
|
||||||
|
xb: float,
|
||||||
|
yb: float,
|
||||||
|
color: tuple[int, int, int],
|
||||||
|
) -> None:
|
||||||
|
x0i = int(round(xa))
|
||||||
|
y0i = int(round(ya))
|
||||||
|
x1i = int(round(xb))
|
||||||
|
y1i = int(round(yb))
|
||||||
|
dx = abs(x1i - x0i)
|
||||||
|
sx_step = 1 if x0i < x1i else -1
|
||||||
|
dy = -abs(y1i - y0i)
|
||||||
|
sy_step = 1 if y0i < y1i else -1
|
||||||
|
err = dx + dy
|
||||||
|
x = x0i
|
||||||
|
y = y0i
|
||||||
|
while True:
|
||||||
|
if 0 <= x < width and 0 <= y < height:
|
||||||
|
p = (y * width + x) * 3
|
||||||
|
rgb[p + 0] = color[0]
|
||||||
|
rgb[p + 1] = color[1]
|
||||||
|
rgb[p + 2] = color[2]
|
||||||
|
if x == x1i and y == y1i:
|
||||||
|
break
|
||||||
|
e2 = 2 * err
|
||||||
|
if e2 >= dy:
|
||||||
|
err += dy
|
||||||
|
x += sx_step
|
||||||
|
if e2 <= dx:
|
||||||
|
err += dx
|
||||||
|
y += sy_step
|
||||||
|
|
||||||
|
if wireframe:
|
||||||
|
wf = (225, 232, 246)
|
||||||
|
for i0, i1, i2 in terrain_faces:
|
||||||
|
draw_line(sx[i0], sy[i0], sx[i1], sy[i1], wf)
|
||||||
|
draw_line(sx[i1], sy[i1], sx[i2], sy[i2], wf)
|
||||||
|
draw_line(sx[i2], sy[i2], sx[i0], sy[i0], wf)
|
||||||
|
|
||||||
|
if areal_overlay:
|
||||||
|
for area in areals:
|
||||||
|
verts = area["vertices"]
|
||||||
|
if len(verts) < 2:
|
||||||
|
continue
|
||||||
|
color = _color_for_class(int(area["class_id"]))
|
||||||
|
projected = [project_point(x, y, z + 0.35) for x, y, z in verts]
|
||||||
|
for i in range(len(projected)):
|
||||||
|
x0, y0, _ = projected[i]
|
||||||
|
x1, y1, _ = projected[(i + 1) % len(projected)]
|
||||||
|
draw_line(x0, y0, x1, y1, color)
|
||||||
|
|
||||||
|
return rgb
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_render(args: argparse.Namespace) -> int:
|
||||||
|
msh_path = Path(args.land_msh).resolve()
|
||||||
|
map_path = Path(args.land_map).resolve() if args.land_map else None
|
||||||
|
output_path = Path(args.output).resolve()
|
||||||
|
|
||||||
|
positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces))
|
||||||
|
areals: list[dict[str, Any]] = []
|
||||||
|
map_meta: dict[str, int] = {"areal_count": 0, "cells_x": 0, "cells_y": 0}
|
||||||
|
if map_path:
|
||||||
|
areals, map_meta = load_areal_map(map_path)
|
||||||
|
|
||||||
|
rgb = _render_scene(
|
||||||
|
positions,
|
||||||
|
faces,
|
||||||
|
areals,
|
||||||
|
width=int(args.width),
|
||||||
|
height=int(args.height),
|
||||||
|
yaw_deg=float(args.yaw),
|
||||||
|
pitch_deg=float(args.pitch),
|
||||||
|
wireframe=bool(args.wireframe),
|
||||||
|
areal_overlay=bool(args.overlay_areals),
|
||||||
|
)
|
||||||
|
_write_ppm(output_path, int(args.width), int(args.height), rgb)
|
||||||
|
|
||||||
|
print(f"Rendered terrain : {msh_path}")
|
||||||
|
if map_path:
|
||||||
|
print(f"Areal overlay : {map_path}")
|
||||||
|
print(f"Output : {output_path}")
|
||||||
|
print(
|
||||||
|
"Terrain geometry : "
|
||||||
|
f"vertices={terrain_meta['vertex_count']}, "
|
||||||
|
f"faces={terrain_meta['face_count_rendered']}/{terrain_meta['face_count_valid']} "
|
||||||
|
f"(raw={terrain_meta['face_count_raw']}, dropped={terrain_meta['face_dropped_invalid']})"
|
||||||
|
)
|
||||||
|
if map_path:
|
||||||
|
print(
|
||||||
|
"Areal map : "
|
||||||
|
f"areals={map_meta['areal_count']}, cells={map_meta['cells_x']}x{map_meta['cells_y']}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_export_obj(args: argparse.Namespace) -> int:
|
||||||
|
msh_path = Path(args.land_msh).resolve()
|
||||||
|
map_path = Path(args.land_map).resolve() if args.land_map else None
|
||||||
|
output_path = Path(args.output).resolve()
|
||||||
|
|
||||||
|
positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces))
|
||||||
|
areals: list[dict[str, Any]] = []
|
||||||
|
if map_path and bool(args.include_areals):
|
||||||
|
areals, _ = load_areal_map(map_path)
|
||||||
|
|
||||||
|
_write_obj(
|
||||||
|
output_path,
|
||||||
|
positions,
|
||||||
|
faces,
|
||||||
|
areals,
|
||||||
|
include_areals=bool(args.include_areals),
|
||||||
|
)
|
||||||
|
|
||||||
|
areal_vertices = sum(len(a["vertices"]) for a in areals)
|
||||||
|
print(f"Terrain source : {msh_path}")
|
||||||
|
if map_path:
|
||||||
|
print(f"Areal source : {map_path}")
|
||||||
|
print(f"OBJ output : {output_path}")
|
||||||
|
print(
|
||||||
|
"Terrain geometry : "
|
||||||
|
f"vertices={terrain_meta['vertex_count']}, "
|
||||||
|
f"faces={terrain_meta['face_count_rendered']}/{terrain_meta['face_count_valid']}"
|
||||||
|
)
|
||||||
|
if bool(args.include_areals):
|
||||||
|
print(f"Areal edges : areals={len(areals)}, extra_vertices={areal_vertices}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_render_turntable(args: argparse.Namespace) -> int:
|
||||||
|
msh_path = Path(args.land_msh).resolve()
|
||||||
|
map_path = Path(args.land_map).resolve() if args.land_map else None
|
||||||
|
output_dir = Path(args.output_dir).resolve()
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
frames = int(args.frames)
|
||||||
|
if frames <= 0:
|
||||||
|
raise RuntimeError("--frames must be > 0")
|
||||||
|
|
||||||
|
positions, faces, terrain_meta = load_terrain_msh(msh_path, max_faces=int(args.max_faces))
|
||||||
|
areals: list[dict[str, Any]] = []
|
||||||
|
if map_path:
|
||||||
|
areals, _ = load_areal_map(map_path)
|
||||||
|
|
||||||
|
yaw_start = float(args.yaw_start)
|
||||||
|
yaw_end = float(args.yaw_end)
|
||||||
|
if frames == 1:
|
||||||
|
yaws = [yaw_start]
|
||||||
|
else:
|
||||||
|
step = (yaw_end - yaw_start) / (frames - 1)
|
||||||
|
yaws = [yaw_start + i * step for i in range(frames)]
|
||||||
|
|
||||||
|
prefix = str(args.prefix)
|
||||||
|
for i, yaw in enumerate(yaws):
|
||||||
|
rgb = _render_scene(
|
||||||
|
positions,
|
||||||
|
faces,
|
||||||
|
areals,
|
||||||
|
width=int(args.width),
|
||||||
|
height=int(args.height),
|
||||||
|
yaw_deg=yaw,
|
||||||
|
pitch_deg=float(args.pitch),
|
||||||
|
wireframe=bool(args.wireframe),
|
||||||
|
areal_overlay=bool(args.overlay_areals),
|
||||||
|
)
|
||||||
|
out = output_dir / f"{prefix}_{i:03d}.ppm"
|
||||||
|
_write_ppm(out, int(args.width), int(args.height), rgb)
|
||||||
|
|
||||||
|
print(f"Turntable source : {msh_path}")
|
||||||
|
if map_path:
|
||||||
|
print(f"Areal source : {map_path}")
|
||||||
|
print(f"Output dir : {output_dir}")
|
||||||
|
print(f"Frames : {frames} ({yaws[0]:.3f} -> {yaws[-1]:.3f} yaw)")
|
||||||
|
print(
|
||||||
|
"Terrain geometry : "
|
||||||
|
f"vertices={terrain_meta['vertex_count']}, faces={terrain_meta['face_count_rendered']}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_render_batch(args: argparse.Namespace) -> int:
|
||||||
|
maps_root = Path(args.maps_root).resolve()
|
||||||
|
output_dir = Path(args.output_dir).resolve()
|
||||||
|
msh_paths = sorted(maps_root.rglob("Land.msh"))
|
||||||
|
if not msh_paths:
|
||||||
|
raise RuntimeError(f"no Land.msh files under {maps_root}")
|
||||||
|
|
||||||
|
rendered = 0
|
||||||
|
skipped = 0
|
||||||
|
for msh_path in msh_paths:
|
||||||
|
map_path = msh_path.with_name("Land.map")
|
||||||
|
if not map_path.exists():
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
rel = msh_path.parent.relative_to(maps_root)
|
||||||
|
out = output_dir / f"{rel.as_posix().replace('/', '__')}.ppm"
|
||||||
|
cmd_render(
|
||||||
|
argparse.Namespace(
|
||||||
|
land_msh=str(msh_path),
|
||||||
|
land_map=str(map_path),
|
||||||
|
output=str(out),
|
||||||
|
max_faces=args.max_faces,
|
||||||
|
width=args.width,
|
||||||
|
height=args.height,
|
||||||
|
yaw=args.yaw,
|
||||||
|
pitch=args.pitch,
|
||||||
|
wireframe=args.wireframe,
|
||||||
|
overlay_areals=args.overlay_areals,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
rendered += 1
|
||||||
|
|
||||||
|
print(f"Batch summary: rendered={rendered}, skipped_no_map={skipped}, output_dir={output_dir}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Software 3D terrain renderer (Land.msh + optional Land.map overlay)."
|
||||||
|
)
|
||||||
|
sub = parser.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
render = sub.add_parser("render", help="Render one terrain map to PPM.")
|
||||||
|
render.add_argument("--land-msh", required=True, help="Path to Land.msh")
|
||||||
|
render.add_argument("--land-map", help="Path to Land.map (optional)")
|
||||||
|
render.add_argument("--output", required=True, help="Output .ppm path")
|
||||||
|
render.add_argument("--max-faces", type=int, default=220000, help="Face limit (default: 220000)")
|
||||||
|
render.add_argument("--width", type=int, default=1280, help="Image width (default: 1280)")
|
||||||
|
render.add_argument("--height", type=int, default=720, help="Image height (default: 720)")
|
||||||
|
render.add_argument("--yaw", type=float, default=38.0, help="Yaw angle in degrees (default: 38)")
|
||||||
|
render.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)")
|
||||||
|
render.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay")
|
||||||
|
render.add_argument(
|
||||||
|
"--overlay-areals",
|
||||||
|
action="store_true",
|
||||||
|
help="Draw ArealMap polygon overlay",
|
||||||
|
)
|
||||||
|
render.set_defaults(func=cmd_render)
|
||||||
|
|
||||||
|
export_obj = sub.add_parser("export-obj", help="Export terrain (and optional areal edges) to OBJ.")
|
||||||
|
export_obj.add_argument("--land-msh", required=True, help="Path to Land.msh")
|
||||||
|
export_obj.add_argument("--land-map", help="Path to Land.map (optional)")
|
||||||
|
export_obj.add_argument("--output", required=True, help="Output .obj path")
|
||||||
|
export_obj.add_argument("--max-faces", type=int, default=0, help="Face limit (0 = all)")
|
||||||
|
export_obj.add_argument(
|
||||||
|
"--include-areals",
|
||||||
|
action="store_true",
|
||||||
|
help="Export areal polygons as OBJ polyline object",
|
||||||
|
)
|
||||||
|
export_obj.set_defaults(func=cmd_export_obj)
|
||||||
|
|
||||||
|
turn = sub.add_parser("render-turntable", help="Render turntable frame sequence to PPM.")
|
||||||
|
turn.add_argument("--land-msh", required=True, help="Path to Land.msh")
|
||||||
|
turn.add_argument("--land-map", help="Path to Land.map (optional)")
|
||||||
|
turn.add_argument("--output-dir", required=True, help="Output directory for frames")
|
||||||
|
turn.add_argument("--prefix", default="frame", help="Frame filename prefix (default: frame)")
|
||||||
|
turn.add_argument("--frames", type=int, default=36, help="Frame count (default: 36)")
|
||||||
|
turn.add_argument("--yaw-start", type=float, default=0.0, help="Start yaw in degrees (default: 0)")
|
||||||
|
turn.add_argument("--yaw-end", type=float, default=360.0, help="End yaw in degrees (default: 360)")
|
||||||
|
turn.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)")
|
||||||
|
turn.add_argument("--max-faces", type=int, default=160000, help="Face limit (default: 160000)")
|
||||||
|
turn.add_argument("--width", type=int, default=960, help="Image width (default: 960)")
|
||||||
|
turn.add_argument("--height", type=int, default=540, help="Image height (default: 540)")
|
||||||
|
turn.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay")
|
||||||
|
turn.add_argument(
|
||||||
|
"--overlay-areals",
|
||||||
|
action="store_true",
|
||||||
|
help="Draw ArealMap polygon overlay",
|
||||||
|
)
|
||||||
|
turn.set_defaults(func=cmd_render_turntable)
|
||||||
|
|
||||||
|
batch = sub.add_parser("render-batch", help="Render all MAPS/**/Land.msh under root.")
|
||||||
|
batch.add_argument(
|
||||||
|
"--maps-root",
|
||||||
|
default="tmp/gamedata/DATA/MAPS",
|
||||||
|
help="Root directory with MAPS subfolders (default: tmp/gamedata/DATA/MAPS)",
|
||||||
|
)
|
||||||
|
batch.add_argument("--output-dir", required=True, help="Directory for output PPM files")
|
||||||
|
batch.add_argument("--max-faces", type=int, default=90000, help="Face limit per map (default: 90000)")
|
||||||
|
batch.add_argument("--width", type=int, default=960, help="Image width (default: 960)")
|
||||||
|
batch.add_argument("--height", type=int, default=540, help="Image height (default: 540)")
|
||||||
|
batch.add_argument("--yaw", type=float, default=38.0, help="Yaw angle in degrees (default: 38)")
|
||||||
|
batch.add_argument("--pitch", type=float, default=26.0, help="Pitch angle in degrees (default: 26)")
|
||||||
|
batch.add_argument("--wireframe", action="store_true", help="Draw terrain wireframe overlay")
|
||||||
|
batch.add_argument(
|
||||||
|
"--overlay-areals",
|
||||||
|
action="store_true",
|
||||||
|
help="Draw ArealMap polygon overlay",
|
||||||
|
)
|
||||||
|
batch.set_defaults(func=cmd_render_batch)
|
||||||
|
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args()
|
||||||
|
return int(args.func(args))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user