Merge gfx/optional (asset-free sky & grading builder) into integration

This commit is contained in:
pkuzic
2026-09-26 18:00:44 +01:00
21 changed files with 2534 additions and 0 deletions
+2
View File
@@ -43,6 +43,8 @@ the reference 1.12 slots, so their fidelity fixes remain active in Classic: auth
alpha tracks affect the layer, non-white M2 colours reach the mesh, deterministic captures pose at
their pinned time, and the batch-order base reserves the lower celestial band and final fog cone.
Asset-free local builder and bilingual install guide: [`Optional/sky-and-grading/`](Optional/sky-and-grading/README.md).
Players reach all of it from **Options -> Advanced Graphics** (a Graphics Preset over everything, a Render Distance slider, a Lighting Quality preset Off / Low / Medium / High / Ultra plus the individual rows; Classic / Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default /
Bright presets.
+179
View File
@@ -0,0 +1,179 @@
# Optional skyboxes and colour grading
This asset-free kit builds an optional MPQ on your machine. It adds zone
skyboxes for Burning Steppes, Blasted Lands, and Mount Hyjal; project-authored
per-zone colour grading for Elwynn Forest, Duskwood, Westfall, and Redridge;
and, with `--wf-colours`, a moderated import of night colours from your own WoW
Forever installation.
The repository contains only scripts and benilla's own grading LUTs/table. The
Blizzard models, textures, and lighting tables are read from installations you
own and appear only in your local build directory.
## Requirements
- Windows and Python 3.8 or newer.
- A World of Warcraft 1.12.1 client and its `Data` directory.
- For skyboxes, a local WoW Forever `_classic_beta_` or current retail install.
Point the command at the shared World of Warcraft root (the directory with
`.build.info` and `Data`), or at `_classic_beta_` itself.
- Git and the external `wowdev/pywowlib` checkout pinned below. It is not
redistributed here.
- 64-bit StormLib 9.40 (`StormLib.dll`) from the official
[StormLib releases](https://github.com/ladislav-zezula/StormLib/releases/tag/v9.40).
Download `stormlib_dll.zip` and use its x64 DLL. StormLib is not redistributed.
- Internet access on the first run for the public wow-listfile and public TACT
key list, unless `--listfile` and `--tact-keys` name local copies.
## Install, one command per step
Run these in PowerShell. Change the example paths for your machine.
1. Clone the exact pywowlib revision used by the converter.
```powershell
git clone https://github.com/wowdev/pywowlib.git C:\Tools\pywowlib
```
2. Pin it to the tested commit.
```powershell
git -C C:\Tools\pywowlib checkout 55276dc5c2195da7fe136638a2a59716622f8c65
```
3. Install the Python modules needed by the M2 reader. pywowlib remains an
external checkout; this installs dependencies only.
```powershell
py -3 -m pip install bidict==0.23.1 multimethod numpy
```
4. Download StormLib 9.40's `stormlib_dll.zip` from the release linked above,
extract the 64-bit `StormLib.dll`, and place it at
`C:\Tools\StormLib\StormLib.dll`.
5. Close the game and build the candidate. The tool never writes to either
input installation.
```powershell
py -3 .\tools\build_sky_patch.py --wow-forever "C:\Games\World of Warcraft" --client-data "C:\Games\WoW-1.12.1\Data" --pywowlib "C:\Tools\pywowlib" --stormlib "C:\Tools\StormLib\StormLib.dll" --out "C:\Temp\benilla-sky"
```
6. Verify an isolated Data copy before installation. `C:\Temp\WoW-test\Data`
should be a copy or hard-link mirror in which only `patch-Z.mpq` is an
ordinary copy; replace that copy with the newly built archive first.
```powershell
py -3 .\tools\verify_client_chain.py "C:\Temp\WoW-test\Data" --stage "C:\Temp\benilla-sky\stage" --stormlib "C:\Tools\StormLib\StormLib.dll"
```
7. Back up the live target archive if one exists.
```powershell
Copy-Item "C:\Games\WoW-1.12.1\Data\patch-Z.mpq" "C:\Games\WoW-1.12.1\Data\patch-Z.before-benilla.mpq.bak"
```
8. Copy the verified candidate into the client.
```powershell
Copy-Item -Force "C:\Temp\benilla-sky\patch-Z.MPQ" "C:\Games\WoW-1.12.1\Data\patch-Z.mpq"
```
If no `patch-Z.mpq` existed, skip step 7. The build report's `archive_base`
field records whether the builder preserved an existing patch-Z.
### Variants
Build only the project-owned grading table and LUTs; this needs no modern WoW
install or pywowlib:
```powershell
py -3 .\tools\build_sky_patch.py --grading-only --client-data "C:\Games\WoW-1.12.1\Data" --stormlib "C:\Tools\StormLib\StormLib.dll" --out "C:\Temp\benilla-grading"
```
Add moderated WoW Forever night colours. LightData is extracted directly from
your installation; no derived LightData is shipped here:
```powershell
py -3 .\tools\build_sky_patch.py --wow-forever "C:\Games\World of Warcraft" --client-data "C:\Games\WoW-1.12.1\Data" --pywowlib "C:\Tools\pywowlib" --stormlib "C:\Tools\StormLib\StormLib.dll" --wf-colours --out "C:\Temp\benilla-sky-wf"
```
## Enable in game
Open **Options → Advanced Graphics** and enable **Zone Skyboxes** and
**Colour Grading**. The equivalent console commands are:
```text
/console zoneSkyboxes 1
/console colorGrading 1
```
Use `0` instead of `1` to disable either effect without removing the MPQ.
## MPQ load order: read before copying
All five mutually dependent lighting DBCs must win from the same, highest
letter patch that carries any of them. The default is `patch-Z.mpq`. If the
client already has patch-Z, the builder copies that archive and overlays the
new files so unrelated members survive; install the resulting candidate as a
replacement, not as a second archive.
Do not blindly rename the output. A later-mounted archive that also contains
`Light.dbc`, `LightParams.dbc`, `LightIntBand.dbc`, `LightFloatBand.dbc`, or
`LightSkybox.dbc` can split the table set and break the sky. Some custom clients
use punctuation after ordinary letters; in that case merge this stage into the
actual last patch or remove the conflicting DBC copies, then run
`verify_client_chain.py --require-winner ANY`. A conventional patch-Z setup
should keep the default strict check.
The extended four-field `LightSkybox.dbc` is for benilla. Do not use this patch
with an unmodified stock 1.12 executable that expects the two-field table.
## Uninstall
Close the game. If the build preserved an existing patch-Z, restore the backup:
```powershell
Move-Item -Force "C:\Games\WoW-1.12.1\Data\patch-Z.before-benilla.mpq.bak" "C:\Games\WoW-1.12.1\Data\patch-Z.mpq"
```
If there was no prior patch-Z and this kit created it, remove only that exact
archive:
```powershell
Remove-Item -LiteralPath "C:\Games\WoW-1.12.1\Data\patch-Z.mpq"
```
Deleting a pre-existing patch-Z would remove unrelated custom content, so use
`build-report.json` and your backup to distinguish these cases.
## Troubleshooting
- **pywowlib revision mismatch:** run the checkout command from step 2. The
builder deliberately rejects another revision so M2 layout behaviour cannot
drift silently.
- **StormLib not found:** pass `--stormlib` or set `STORMLIB_PATH` to the x64
DLL. A 32-bit DLL cannot load into 64-bit Python.
- **No CASC build/config:** point `--wow-forever` at the World of Warcraft root
with `.build.info` and `Data`, or at its `_classic_beta_` child.
- **Missing/encrypted CASC block:** update the public TACT key file or rerun
without a stale `--tact-keys` copy. The tool uses public keys only.
- **Unexpected source schema/clone start:** another addon has already changed
the winning lighting DBCs. Remove that conflict or merge deliberately; the
tool refuses to guess.
- **Sky does not appear:** enable `zoneSkyboxes`, fully restart the client, and
run `verify_client_chain.py` against the actual Data directory.
- **Bands fail:** run `check_bands.py <Data directory> --stormlib <DLL>`. Correct
clone keys are `(P-1)*18+b+1` and `(P-1)*6+b+1`.
- **Output already exists:** choose a fresh `--out`, or pass `--force` only when
replacing the candidate archive in that output directory is intended.
## Credits and rights
WoW Forever and retail skybox art is © Blizzard Entertainment and is extracted
locally by the user from their own installation. No Blizzard art or client DBC
is distributed here.
The colour-grading technique is based on WarcraftXL's `wxl-retail-grading` work
by iThorgrim, used with attribution. The grading LUTs and `MonkeyZoneGrade.dbc`
in `data/` are original benilla project data, licensed MIT OR Apache-2.0.
+167
View File
@@ -0,0 +1,167 @@
# Дополнительные небеса и цветокоррекция
Этот комплект без игровых ресурсов собирает MPQ локально. Он добавляет небеса
зон для Burning Steppes, Blasted Lands и Mount Hyjal, собственную
цветокоррекцию benilla для Elwynn Forest, Duskwood, Westfall и Redridge, а с
ключом `--wf-colours` — умеренно смешанные ночные цвета из вашей установки WoW
Forever.
В репозитории находятся только скрипты и созданные проектом LUT/таблица. Модели,
текстуры и исходные DBC Blizzard читаются из ваших установок и записываются
только во внешний каталог сборки.
## Требования
- Windows и Python 3.8 или новее.
- Клиент World of Warcraft 1.12.1 и его каталог `Data`.
- Для небес — локальная установка WoW Forever `_classic_beta_` либо retail.
- Git и внешний `wowdev/pywowlib` точной ревизии ниже.
- 64-битный StormLib 9.40 (`StormLib.dll`) из официальных
[релизов StormLib](https://github.com/ladislav-zezula/StormLib/releases/tag/v9.40).
Скачайте `stormlib_dll.zip` и используйте x64 DLL.
- Интернет при первом запуске для публичного listfile и списка публичных TACT
ключей, либо локальные файлы через `--listfile` и `--tact-keys`.
## Установка — одна команда на шаг
Команды выполняются в PowerShell; замените пути на свои.
1. Клонируйте pywowlib.
```powershell
git clone https://github.com/wowdev/pywowlib.git C:\Tools\pywowlib
```
2. Выберите проверенную ревизию.
```powershell
git -C C:\Tools\pywowlib checkout 55276dc5c2195da7fe136638a2a59716622f8c65
```
3. Установите зависимости Python. Сам pywowlib остаётся внешним checkout.
```powershell
py -3 -m pip install bidict==0.23.1 multimethod numpy
```
4. Скачайте `stormlib_dll.zip` версии 9.40 по ссылке выше и распакуйте x64
`StormLib.dll` в `C:\Tools\StormLib\StormLib.dll`.
5. Закройте игру и соберите кандидат. Скрипт не пишет в исходные установки.
```powershell
py -3 .\tools\build_sky_patch.py --wow-forever "C:\Games\World of Warcraft" --client-data "C:\Games\WoW-1.12.1\Data" --pywowlib "C:\Tools\pywowlib" --stormlib "C:\Tools\StormLib\StormLib.dll" --out "C:\Temp\benilla-sky"
```
6. Сначала проверьте изолированную копию `Data`. Все файлы можно сделать
жёсткими ссылками, но `patch-Z.mpq` должен быть обычной копией; замените его
собранным кандидатом и выполните:
```powershell
py -3 .\tools\verify_client_chain.py "C:\Temp\WoW-test\Data" --stage "C:\Temp\benilla-sky\stage" --stormlib "C:\Tools\StormLib\StormLib.dll"
```
7. Если живой `patch-Z.mpq` уже есть, создайте резервную копию.
```powershell
Copy-Item "C:\Games\WoW-1.12.1\Data\patch-Z.mpq" "C:\Games\WoW-1.12.1\Data\patch-Z.before-benilla.mpq.bak"
```
8. Установите проверенный кандидат.
```powershell
Copy-Item -Force "C:\Temp\benilla-sky\patch-Z.MPQ" "C:\Games\WoW-1.12.1\Data\patch-Z.mpq"
```
Если patch-Z раньше не существовал, пропустите шаг 7. Поле `archive_base` в
`build-report.json` показывает, был ли старый архив сохранён внутри кандидата.
### Варианты
Только собственная цветокоррекция, без современной установки и pywowlib:
```powershell
py -3 .\tools\build_sky_patch.py --grading-only --client-data "C:\Games\WoW-1.12.1\Data" --stormlib "C:\Tools\StormLib\StormLib.dll" --out "C:\Temp\benilla-grading"
```
Небеса и умеренные ночные цвета WoW Forever, извлечённые из вашей LightData:
```powershell
py -3 .\tools\build_sky_patch.py --wow-forever "C:\Games\World of Warcraft" --client-data "C:\Games\WoW-1.12.1\Data" --pywowlib "C:\Tools\pywowlib" --stormlib "C:\Tools\StormLib\StormLib.dll" --wf-colours --out "C:\Temp\benilla-sky-wf"
```
## Включение в игре
Включите **Zone Skyboxes** и **Colour Grading** в **Options → Advanced
Graphics**. Те же настройки через консоль:
```text
/console zoneSkyboxes 1
/console colorGrading 1
```
Значение `0` отключает эффект без удаления MPQ.
## Порядок загрузки MPQ
Все пять связанных DBC освещения должны загружаться из одного самого старшего
буквенного патча, который содержит любой из них. По умолчанию это
`patch-Z.mpq`. Если он уже существует, сборщик копирует его и накладывает новые
файлы, сохраняя постороннее содержимое; результат устанавливается вместо
старого архива.
Не переименовывайте результат вслепую. Более поздний архив с `Light.dbc`,
`LightParams.dbc`, `LightIntBand.dbc`, `LightFloatBand.dbc` или
`LightSkybox.dbc` разделит согласованный набор. Некоторые кастомные клиенты
используют символы, сортирующиеся после букв. Тогда объедините `stage` с
реально последним патчем либо удалите конфликтующие DBC и проверьте цепочку с
`--require-winner ANY`. Для обычного patch-Z оставьте строгую проверку.
Расширенный четырёхполевой `LightSkybox.dbc` предназначен для benilla. Не
используйте его с неизменённым оригинальным exe 1.12, ожидающим два поля.
## Удаление
Закройте игру. Если patch-Z существовал ранее, восстановите резервную копию:
```powershell
Move-Item -Force "C:\Games\WoW-1.12.1\Data\patch-Z.before-benilla.mpq.bak" "C:\Games\WoW-1.12.1\Data\patch-Z.mpq"
```
Если архив был создан этим комплектом с нуля, удалите только его:
```powershell
Remove-Item -LiteralPath "C:\Games\WoW-1.12.1\Data\patch-Z.mpq"
```
Удаление старого patch-Z уничтожит другое пользовательское содержимое.
Проверяйте `archive_base` и сохраняйте резервную копию.
## Решение проблем
- **Неверная ревизия pywowlib:** повторите шаг 2; сборщик намеренно не принимает
другую ревизию.
- **StormLib не найден:** укажите `--stormlib` либо `STORMLIB_PATH`; DLL должна
быть 64-битной для 64-битного Python.
- **CASC build/config не найден:** укажите корень World of Warcraft с
`.build.info` и `Data` либо его дочерний `_classic_beta_`.
- **Зашифрованный/отсутствующий блок:** обновите публичный список TACT ключей.
Приватные ключи не используются.
- **Неожиданная схема или номер клона:** другой аддон уже изменил выигрывающие
DBC. Уберите конфликт или выполните осознанное объединение.
- **Небо не видно:** включите `zoneSkyboxes`, полностью перезапустите клиент и
запустите `verify_client_chain.py` на его настоящем `Data`.
- **Ошибка band rows:** запустите `check_bands.py <Data> --stormlib <DLL>`.
Правильные ключи: `(P-1)*18+b+1` и `(P-1)*6+b+1`.
- **Выходной архив уже существует:** выберите новый `--out` либо используйте
`--force` только для намеренной замены кандидата.
## Авторы и права
Графика небес WoW Forever/retail принадлежит © Blizzard Entertainment и
извлекается пользователем локально из собственной установки. Репозиторий не
распространяет графику Blizzard или DBC клиента.
Метод цветокоррекции основан на `wxl-retail-grading` проекта WarcraftXL автора
iThorgrim и используется с указанием авторства. LUT и `MonkeyZoneGrade.dbc` в
`data/` созданы проектом benilla и лицензированы MIT OR Apache-2.0.
+13
View File
@@ -0,0 +1,13 @@
# License
The files in this directory are generated by `tools/build_grading_data.py` and
are original benilla project data. They are licensed under either of:
- MIT (`../../../LICENSE-MIT`)
- Apache License 2.0 (`../../../LICENSE-APACHE`)
at your option.
No Blizzard asset or client-derived DBC is stored here. Sky models, textures,
and lighting DBC merges are produced only from the user's own installations at
build time and are written outside the repository.
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""Build MONKEY post-lane BLP2 LUT strips and MonkeyZoneGrade.dbc.
Outputs ordinary loose patch-tree files below this directory. Copy the resulting
DBFilesClient/ and World/ trees into an isolated Data directory for capture runs.
"""
from pathlib import Path
import math
import struct
ROOT = Path(__file__).resolve().parent.parent / "data"
LUT_DIR = ROOT / "World" / "LUTs"
DBC_DIR = ROOT / "DBFilesClient"
def clamp(value):
return max(0.0, min(1.0, value))
def identity(c):
return c
def elwynn(c):
r, g, b = c
return (clamp(r * 1.025 + 0.010 * g), clamp(g * 1.035 + 0.012 * r), clamp(b * 0.975))
def duskwood(c):
r, g, b = c
y = r * 0.299 + g * 0.587 + b * 0.114
r, g, b = (y + (r - y) * 0.78, y + (g - y) * 0.78, y + (b - y) * 0.78)
return (clamp(r * 0.94), clamp(g * 0.97), clamp(b * 1.045 + 0.008))
def westfall(c):
r, g, b = c
return (clamp(r * 1.045 + 0.008 * g), clamp(g * 1.018), clamp(b * 0.94))
def redridge(c):
r, g, b = c
return (clamp(r * 1.055 + 0.008), clamp(g * 0.982), clamp(b * 0.955))
def write_blp(path, transform):
width, height = 1024, 32
header_bytes = 148
palette_bytes = 256 * 4
pixel_offset = header_bytes + palette_bytes
pixel_size = width * height * 4
header = bytearray(header_bytes)
header[0:4] = b"BLP2"
struct.pack_into("<I", header, 4, 1) # Direct content
header[8:12] = bytes((3, 8, 0, 0)) # Raw3 BGRA8, no mip chain
struct.pack_into("<II", header, 12, width, height)
struct.pack_into("<I", header, 20, pixel_offset)
struct.pack_into("<I", header, 84, pixel_size)
pixels = bytearray()
for g in range(32):
for b in range(32):
for r in range(32):
source = (r / 31.0, g / 31.0, b / 31.0)
rr, gg, bb = transform(source)
rgba = [round(clamp(v) * 255.0) for v in (rr, gg, bb)]
pixels.extend((rgba[2], rgba[1], rgba[0], 255)) # BLP Raw3 is BGRA
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(header + bytes(palette_bytes) + pixels)
def write_dbc(path):
# ID, AreaID, DayLUT, NightLUT, Strength. Area IDs are the zone-level AreaTable rows.
rows = [
(1, 12, "World\\LUTs\\ElwynnWarmGreen.blp", "World\\LUTs\\Identity.blp", 0.34),
(2, 10, "World\\LUTs\\Identity.blp", "World\\LUTs\\DuskwoodCold.blp", 0.44),
(3, 40, "World\\LUTs\\WestfallGolden.blp", "World\\LUTs\\Identity.blp", 0.38),
(4, 44, "World\\LUTs\\RedridgeWarmRed.blp", "World\\LUTs\\Identity.blp", 0.34),
]
strings = bytearray(b"\0")
offsets = {"": 0}
def string_offset(value):
if value not in offsets:
offsets[value] = len(strings)
strings.extend(value.encode("utf-8") + b"\0")
return offsets[value]
records = bytearray()
for row_id, area_id, day, night, strength in rows:
records.extend(struct.pack("<IIIIf", row_id, area_id, string_offset(day), string_offset(night), strength))
header = b"WDBC" + struct.pack("<IIII", len(rows), 5, 20, len(strings))
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(header + records + strings)
def main():
for name, transform in [
("Identity.blp", identity),
("ElwynnWarmGreen.blp", elwynn),
("DuskwoodCold.blp", duskwood),
("WestfallGolden.blp", westfall),
("RedridgeWarmRed.blp", redridge),
]:
write_blp(LUT_DIR / name, transform)
write_dbc(DBC_DIR / "MonkeyZoneGrade.dbc")
print(f"wrote 5 LUTs to {LUT_DIR}")
print(f"wrote {DBC_DIR / 'MonkeyZoneGrade.dbc'}")
if __name__ == "__main__":
main()
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""Build an asset-free optional sky and colour-grading MPQ from user installs."""
from __future__ import annotations
import argparse
import csv
import hashlib
import json
from pathlib import Path
import shutil
import sys
import urllib.request
from casc import CascReader
from dbc_build import REQUIRED, build_merged, check_bands
from mpq_tools import Storm, find_stormlib, winning_payloads
from sky_convert import convert_all, extract_sources, load_pywowlib, read_listfile
from wdc import WDC5
from wf_colours import apply_wf_colours
HERE = Path(__file__).resolve().parent
DATA = HERE.parent / "data"
LISTFILE_URL = (
"https://github.com/wowdev/wow-listfile/releases/latest/download/"
"community-listfile.csv"
)
TACT_KEYS_URL = "https://raw.githubusercontent.com/wowdev/TACTKeys/master/WoW.txt"
def sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
while True:
chunk = stream.read(1024 * 1024)
if not chunk:
break
digest.update(chunk)
return digest.hexdigest()
def fetch(url: str, target: Path) -> Path:
if target.is_file() and target.stat().st_size:
return target
target.parent.mkdir(parents=True, exist_ok=True)
partial = target.with_suffix(target.suffix + ".part")
with urllib.request.urlopen(url, timeout=120) as response, partial.open("wb") as output:
shutil.copyfileobj(response, output, 1024 * 1024)
partial.replace(target)
return target
def casc_root(install: Path) -> Path:
install = Path(install).resolve()
if (install / "Data" / "config").is_dir():
return install
if (install.parent / "Data" / "config").is_dir():
return install.parent
raise FileNotFoundError("no shared CASC Data/config below %s or its parent" % install)
def select_build_key(install: Path, preferred: str | None = None) -> tuple[str, str]:
info = install / ".build.info"
if not info.is_file():
raise FileNotFoundError(info)
with info.open("r", encoding="utf-8-sig", newline="") as stream:
reader = csv.reader(stream, delimiter="|")
raw_headers = next(reader)
headers = [header.split("!", 1)[0] for header in raw_headers]
rows = [dict(zip(headers, row)) for row in reader]
active = [row for row in rows if row.get("Active") == "1"] or rows
products = [preferred] if preferred else []
products.extend(name for name in ("wow_classic_beta", "wow") if name != preferred)
for wanted in products:
for row in active:
if row.get("Product") == wanted:
key = row.get("Build Key", "").lower()
if len(key) == 32:
return key, wanted
raise RuntimeError("no active WoW Forever/classic-beta or retail build in .build.info")
def reset_owned_directory(path: Path, parent: Path, force: bool) -> None:
path = path.resolve()
parent = parent.resolve()
if path.parent != parent:
raise RuntimeError("refusing to reset unexpected path %s" % path)
if path.exists():
if not force:
raise FileExistsError("build directory exists; pass --force: %s" % path)
shutil.rmtree(path)
path.mkdir(parents=True)
def is_within(path: Path, parent: Path) -> bool:
try:
path.relative_to(parent)
return True
except ValueError:
return False
def copy_own_grading(stage: Path) -> None:
for source in sorted(DATA.rglob("*")):
if not source.is_file() or source.name == "LICENSE.md":
continue
relative = source.relative_to(DATA)
target = stage / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)
def archive_base(client_data: Path, letter: str) -> Path | None:
wanted = ("patch-%s.mpq" % letter).casefold()
matches = [
path for path in client_data.iterdir()
if path.is_file() and path.name.casefold() == wanted
]
if len(matches) > 1:
raise RuntimeError("multiple case-insensitive matches for %s" % wanted)
return matches[0] if matches else None
def stage_pairs(stage: Path) -> list[tuple[Path, str]]:
return [
(path, str(path.relative_to(stage)).replace("/", "\\"))
for path in sorted(stage.rglob("*"))
if path.is_file()
]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--wow-forever", type=Path, help="WoW Forever or retail install root")
parser.add_argument("--client-data", type=Path, required=True, help="1.12.1 Data directory")
parser.add_argument("--out", type=Path, required=True, help="output directory")
parser.add_argument("--grading-only", action="store_true", help="pack only project-owned grading data")
parser.add_argument("--wf-colours", action="store_true", help="moderate locally extracted WoW Forever night colours")
parser.add_argument("--pywowlib", type=Path, help="pinned external pywowlib checkout")
parser.add_argument("--stormlib", type=Path, help="path to StormLib.dll")
parser.add_argument("--listfile", type=Path, help="existing community-listfile.csv")
parser.add_argument("--tact-keys", type=Path, help="existing public WoW TACT key list")
parser.add_argument("--patch-letter", default="Z", help="single patch character (default: Z)")
parser.add_argument("--force", action="store_true", help="replace an existing output archive")
args = parser.parse_args()
if len(args.patch_letter) != 1 or not args.patch_letter.isalpha():
parser.error("--patch-letter must be one ASCII letter")
if not args.client_data.is_dir():
parser.error("--client-data is not a directory")
if args.wf_colours and args.grading_only:
parser.error("--wf-colours cannot be combined with --grading-only")
if not args.grading_only and (not args.wow_forever or not args.pywowlib):
parser.error("sky builds require --wow-forever and --pywowlib")
return args
def main() -> int:
if sys.version_info < (3, 8):
raise RuntimeError("Python 3.8 or newer is required")
args = parse_args()
output = args.out.resolve()
client_data = args.client_data.resolve()
if is_within(output, client_data):
raise ValueError("--out must not be inside the 1.12.1 Data directory")
if args.wow_forever:
modern_input = casc_root(args.wow_forever)
if is_within(output, modern_input):
raise ValueError("--out must not be inside the modern WoW installation")
output.mkdir(parents=True, exist_ok=True)
archive = output / ("patch-%s.MPQ" % args.patch_letter.upper())
if archive.exists() and not args.force:
raise FileExistsError("output archive exists; pass --force: %s" % archive)
stage, work = output / "stage", output / "work"
reset_owned_directory(stage, output, args.force)
reset_owned_directory(work, output, args.force)
copy_own_grading(stage)
storm = Storm(find_stormlib(args.stormlib))
report: dict[str, object] = {"grading_only": args.grading_only}
if not args.grading_only:
cache = output / "cache"
listfile_path = args.listfile or fetch(LISTFILE_URL, cache / "community-listfile.csv")
tact_keys = args.tact_keys or fetch(TACT_KEYS_URL, cache / "WoW.txt")
install = modern_input
requested_name = args.wow_forever.resolve().name.casefold()
preferred = {
"_classic_beta_": "wow_classic_beta",
"_retail_": "wow",
}.get(requested_name)
build_key, product = select_build_key(install, preferred)
reader = CascReader(str(install), build_key, str(listfile_path), str(tact_keys))
reader.load_encoding()
reader.load_root()
modules = load_pywowlib(args.pywowlib)
source_root = work / "source"
extract_sources(reader, read_listfile(listfile_path), source_root, modules)
conversions = convert_all(source_root, stage, listfile_path, modules)
winners, winner_names = winning_payloads(storm, args.client_data, REQUIRED)
wf_changed = 0
if args.wf_colours:
light_data = WDC5(reader.open_fdid(1375580))
winners["LightIntBand.dbc"], wf_changed = apply_wf_colours(
winners["LightIntBand.dbc"], light_data
)
merged, dbc_report = build_merged(winners)
dbc_dir = stage / "DBFilesClient"
dbc_dir.mkdir(parents=True, exist_ok=True)
for name, payload in merged.items():
(dbc_dir / name).write_bytes(payload)
check_bands(merged["LightIntBand.dbc"], merged["LightFloatBand.dbc"])
report.update(
{
"modern_product": product,
"build_key": build_key,
"dbc_winners": winner_names,
"dbc_merge": dbc_report,
"wf_colour_rows_changed": wf_changed,
"conversions": conversions,
}
)
if archive.exists():
archive.unlink()
base = archive_base(args.client_data, args.patch_letter)
storm.pack(archive, stage_pairs(stage), base_archive=base)
files = {
str(path.relative_to(stage)).replace("/", "\\"): {
"bytes": path.stat().st_size,
"sha256": sha256(path),
}
for path in sorted(stage.rglob("*"))
if path.is_file()
}
report.update(
{
"archive": str(archive),
"archive_base": str(base) if base else None,
"archive_bytes": archive.stat().st_size,
"archive_sha256": sha256(archive),
"stage_files": files,
}
)
(output / "build-report.json").write_text(
json.dumps(report, indent=2) + "\n", encoding="utf-8"
)
print(
"built %s with %d staged files%s"
% (archive, len(files), " (WoW Forever colours)" if args.wf_colours else "")
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+413
View File
@@ -0,0 +1,413 @@
"""Small, read-only local CASC/TACT reader for the installed WoW builds.
The implementation deliberately indexes only the latest local v7 .idx file per
bucket and opens payloads on demand. It never writes to the CASC installation.
"""
from __future__ import annotations
import hashlib
import pathlib
import struct
import zlib
from typing import Dict, Iterable, List, Optional, Tuple
class CascError(Exception):
pass
class EncryptedBlock(CascError):
def __init__(self, key_name: bytes):
super().__init__("encrypted BLTE block; missing TACT key %s" % key_name.hex())
self.key_name = key_name
def _u24be(data: bytes) -> int:
return int.from_bytes(data, "big")
def _rotl32(x: int, n: int) -> int:
return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF
def _salsa20_block(key: bytes, nonce8: bytes, counter: int) -> bytes:
# Salsa20/20 with the 64-bit nonce/64-bit block-counter construction.
if len(key) == 16:
constants = b"expand 16-byte k"
key = key + key
elif len(key) == 32:
constants = b"expand 32-byte k"
else:
raise CascError("Salsa20 key must contain 16 or 32 bytes")
c = struct.unpack("<4I", constants)
k = struct.unpack("<8I", key)
n0, n1 = struct.unpack("<2I", nonce8)
state = [c[0], k[0], k[1], k[2], k[3], c[1], n0, n1,
counter & 0xFFFFFFFF, (counter >> 32) & 0xFFFFFFFF,
c[2], k[4], k[5], k[6], k[7], c[3]]
x = state[:]
for _ in range(10):
# Column round then row round (Salsa20 double-round).
for a, b, c_, d in ((0, 4, 8, 12), (5, 9, 13, 1),
(10, 14, 2, 6), (15, 3, 7, 11),
(0, 1, 2, 3), (5, 6, 7, 4),
(10, 11, 8, 9), (15, 12, 13, 14)):
x[b] ^= _rotl32((x[a] + x[d]) & 0xFFFFFFFF, 7)
x[c_] ^= _rotl32((x[b] + x[a]) & 0xFFFFFFFF, 9)
x[d] ^= _rotl32((x[c_] + x[b]) & 0xFFFFFFFF, 13)
x[a] ^= _rotl32((x[d] + x[c_]) & 0xFFFFFFFF, 18)
return struct.pack("<16I", *((x[i] + state[i]) & 0xFFFFFFFF for i in range(16)))
def salsa20_xor(data: bytes, key: bytes, nonce8: bytes) -> bytes:
out = bytearray(len(data))
for pos in range(0, len(data), 64):
stream = _salsa20_block(key, nonce8, pos // 64)
part = data[pos:pos + 64]
for j, value in enumerate(part):
out[pos + j] = value ^ stream[j]
return bytes(out)
def load_tact_keys(path: pathlib.Path) -> Dict[bytes, bytes]:
keys: Dict[bytes, bytes] = {}
if not path.exists():
return keys
for raw in path.read_text(encoding="utf-8", errors="replace").splitlines():
line = raw.strip()
if not line or line.startswith("#"):
continue
bits = line.replace(";", " ").split()
if len(bits) < 2:
continue
try:
name, key = bytes.fromhex(bits[0]), bytes.fromhex(bits[1])
except ValueError:
continue
if len(name) == 8 and len(key) in (16, 32):
keys[name] = key
return keys
class CascReader:
"""Read a product from one local modern Battle.net CASC installation."""
def __init__(self, install: str, build_key: str,
listfile: Optional[str] = None,
tact_keys: Optional[str] = None):
self.install = pathlib.Path(install)
self.data_dir = self.install / "Data" / "data"
self.config_dir = self.install / "Data" / "config"
self.build_key = build_key.lower()
self.config = self._read_config(self.build_key)
self.keys = load_tact_keys(pathlib.Path(tact_keys)) if tact_keys else {}
self.by_ekey9: Dict[bytes, Tuple[int, int, int]] = {}
self._load_local_indices()
self.names_by_fdid: Dict[int, str] = {}
self.fdids_by_name: Dict[str, int] = {}
if listfile:
self._load_listfile(pathlib.Path(listfile))
self.encoding_map: Dict[bytes, List[bytes]] = {}
self.root_map: Dict[int, List[bytes]] = {}
self.encoding_header = {}
self.root_header = {}
def _config_path(self, key: str) -> pathlib.Path:
return self.config_dir / key[:2] / key[2:4] / key
def _read_config(self, key: str) -> Dict[str, List[str]]:
result: Dict[str, List[str]] = {}
for raw in self._config_path(key).read_text(encoding="ascii").splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
name, value = line.split("=", 1)
result[name.strip()] = value.strip().split()
return result
def _latest_indices(self) -> Iterable[pathlib.Path]:
groups: Dict[str, pathlib.Path] = {}
for path in self.data_dir.glob("*.idx"):
bucket = path.name[:2].lower()
old = groups.get(bucket)
# Generation is encoded in the remaining filename hex. The newest
# index in each bucket is sufficient because local indices are full.
if old is None or int(path.stem[2:], 16) > int(old.stem[2:], 16):
groups[bucket] = path
if len(groups) != 16:
raise CascError("expected 16 local index buckets, found %d" % len(groups))
return (groups["%02x" % i] for i in range(16))
def _load_local_indices(self) -> None:
for expected_bucket, path in enumerate(self._latest_indices()):
data = path.read_bytes()
if len(data) < 40:
raise CascError("short local index " + str(path))
header_len, = struct.unpack_from("<I", data, 0)
version, bucket, extra, size_bytes, offset_bytes, key_bytes, checksum_bytes = \
struct.unpack_from("<H6B", data, 8)
if header_len != 16 or version != 7 or bucket != expected_bucket:
raise CascError("unexpected v7 index header in " + str(path))
if (extra, size_bytes, offset_bytes, key_bytes) != (0, 4, 5, 9):
raise CascError("unsupported v7 index geometry in " + str(path))
entries_len, = struct.unpack_from("<I", data, 32)
entry_size = key_bytes + offset_bytes + size_bytes
if entries_len % entry_size:
raise CascError("misaligned index entries in " + str(path))
pos = 40
end = pos + entries_len
if end > len(data):
raise CascError("truncated index entries in " + str(path))
while pos < end:
key9 = data[pos:pos + key_bytes]
packed = int.from_bytes(data[pos + key_bytes:pos + key_bytes + offset_bytes], "big")
stored_size = int.from_bytes(
data[pos + key_bytes + offset_bytes:pos + entry_size], "little")
archive = packed >> 30
offset = packed & 0x3FFFFFFF
self.by_ekey9[key9] = (archive, offset, stored_size)
pos += entry_size
def _load_listfile(self, path: pathlib.Path) -> None:
with path.open("r", encoding="utf-8", errors="replace") as stream:
for raw in stream:
try:
left, name = raw.rstrip("\r\n").split(";", 1)
fdid = int(left)
except (ValueError, TypeError):
continue
normalized = name.replace("\\", "/").lower()
self.names_by_fdid[fdid] = name
self.fdids_by_name[normalized] = fdid
def _decode_blte_chunk(self, chunk: bytes, chunk_index: int) -> bytes:
if not chunk:
return b""
mode, payload = chunk[0:1], chunk[1:]
if mode == b"N":
return payload
if mode == b"Z":
return zlib.decompress(payload)
if mode == b"E":
if len(payload) < 3:
raise CascError("short encrypted BLTE block")
nkey = payload[0]
key_name = payload[1:1 + nkey][::-1]
at = 1 + nkey
niv = payload[at]
iv = bytearray(payload[at + 1:at + 1 + niv])
at += 1 + niv
algorithm = payload[at:at + 1]
ciphertext = payload[at + 1:]
key = self.keys.get(key_name)
if key is None:
raise EncryptedBlock(key_name)
if algorithm != b"S":
raise CascError("unknown BLTE encryption algorithm %r" % algorithm)
for i in range(4):
if i < len(iv):
iv[i] ^= (chunk_index >> (8 * i)) & 0xFF
nonce = bytes(iv[:8]).ljust(8, b"\0")
clear = salsa20_xor(ciphertext, key, nonce)
return self._decode_blte_chunk(clear, chunk_index)
raise CascError("unknown BLTE block mode %r" % mode)
def decode_blte(self, data: bytes) -> bytes:
if data[:4] != b"BLTE":
raise CascError("payload is not BLTE")
header_size, = struct.unpack_from(">I", data, 4)
if header_size == 0:
return self._decode_blte_chunk(data[8:], 0)
if header_size < 12 or header_size > len(data):
raise CascError("invalid BLTE header size")
flags = data[8]
count = _u24be(data[9:12])
if flags != 0x0F or header_size < 12 + count * 24:
raise CascError("invalid BLTE chunk table")
chunks = []
pos = 12
for _ in range(count):
compressed, decompressed = struct.unpack_from(">II", data, pos)
digest = data[pos + 8:pos + 24]
chunks.append((compressed, decompressed, digest))
pos += 24
pos = header_size
output = []
for index, (compressed, decompressed, digest) in enumerate(chunks):
raw = data[pos:pos + compressed]
if len(raw) != compressed:
raise CascError("truncated BLTE chunk")
if hashlib.md5(raw).digest() != digest:
raise CascError("BLTE chunk checksum mismatch")
clear = self._decode_blte_chunk(raw, index)
if len(clear) != decompressed:
raise CascError("BLTE decompressed-size mismatch")
output.append(clear)
pos += compressed
return b"".join(output)
def open_ekey(self, ekey: bytes) -> bytes:
if isinstance(ekey, str):
ekey = bytes.fromhex(ekey)
location = self.by_ekey9.get(ekey[:9])
if location is None:
raise CascError("encoding key is not present locally: " + ekey.hex())
archive, offset, stored_size = location
data_path = self.data_dir / ("data.%03d" % archive)
with data_path.open("rb") as stream:
stream.seek(offset)
record = stream.read(stored_size)
if len(record) != stored_size or len(record) < 30:
raise CascError("short local CASC record for " + ekey.hex())
# Local data header: reversed 16-byte (zero-extended) EKey, payload size,
# flags/checksum fields; the encoded BLTE stream begins at byte 30.
if record[30:34] != b"BLTE":
raise CascError("local CASC record has no BLTE payload")
return self.decode_blte(record[30:])
def load_encoding(self) -> None:
pair = self.config.get("encoding")
if not pair or len(pair) < 2:
raise CascError("build config has no encoding CKey/EKey pair")
data = self.open_ekey(bytes.fromhex(pair[1]))
self._parse_encoding(data)
def _parse_encoding(self, data: bytes) -> None:
if data[:2] != b"EN":
raise CascError("bad encoding-file signature")
# Header fields are big-endian on disk.
version, ckey_size, ekey_size = data[2], data[3], data[4]
cpage_kib, epage_kib = struct.unpack_from(">HH", data, 5)
cpages, epages = struct.unpack_from(">II", data, 9)
unknown = data[17]
espec_size, = struct.unpack_from(">I", data, 18)
self.encoding_header = dict(version=version, ckey_size=ckey_size,
ekey_size=ekey_size, cpage_kib=cpage_kib,
epage_kib=epage_kib, cpages=cpages,
epages=epages, unknown=unknown,
espec_size=espec_size,
decoded_size=len(data))
# The ESpec string block follows the 22-byte header. Each CKey page-index
# entry is first-key+MD5; the fixed-size pages immediately follow it.
pos = 22 + espec_size
cpage_size = cpage_kib * 1024
epage_size = epage_kib * 1024
cindex_pos = pos
cdata_pos = cindex_pos + cpages * (ckey_size + 16)
mapping: Dict[bytes, List[bytes]] = {}
for page in range(cpages):
page_data = data[cdata_pos + page * cpage_size:cdata_pos + (page + 1) * cpage_size]
at = 0
while at < len(page_data):
if at + 2 > len(page_data):
break
count = int.from_bytes(page_data[at:at + 2], "little")
at += 2
if count == 0:
break
need = 4 + ckey_size + count * ekey_size
if at + need > len(page_data):
raise CascError("truncated encoding CKey page")
_decoded_size = int.from_bytes(page_data[at:at + 4], "big")
at += 4
ckey = page_data[at:at + ckey_size]
at += ckey_size
ekeys = []
for _ in range(count):
ekeys.append(page_data[at:at + ekey_size])
at += ekey_size
mapping[ckey] = ekeys
self.encoding_map = mapping
def open_ckey(self, ckey: bytes) -> bytes:
if isinstance(ckey, str):
ckey = bytes.fromhex(ckey)
if not self.encoding_map:
self.load_encoding()
ekeys = self.encoding_map.get(ckey)
if not ekeys:
raise CascError("content key absent from encoding file: " + ckey.hex())
errors = []
for ekey in ekeys:
try:
return self.open_ekey(ekey)
except CascError as exc:
errors.append(str(exc))
raise CascError("no local encoding for CKey %s: %s" % (ckey.hex(), "; ".join(errors)))
def load_root(self) -> None:
roots = self.config.get("root")
if not roots:
raise CascError("build config has no root CKey")
data = self.open_ckey(bytes.fromhex(roots[0]))
self._parse_root(data)
def _parse_root(self, data: bytes) -> None:
if data[:4] != b"TSFM" or len(data) < 24:
raise CascError("unsupported root signature")
header_size, version, total_files, named_files, flags = \
struct.unpack_from("<5I", data, 4)
if header_size != 24 or version != 2:
raise CascError("unsupported TSFM root version/header")
mapping: Dict[int, List[bytes]] = {}
pos = header_size
seen_total = 0
seen_named = 0
blocks = 0
while pos < len(data):
if pos + 17 > len(data):
raise CascError("truncated TSFM block header")
count, content_flags, locale_flags = struct.unpack_from("<III", data, pos)
block_meta = data[pos + 12:pos + 17]
if count == 0 or block_meta[:3] != b"\0\0\0":
raise CascError("invalid TSFM block at offset %d" % pos)
delta_pos = pos + 17
ckey_pos = delta_pos + count * 4
hash_pos = ckey_pos + count * 16
# In TSFM v2 bit 0x10 of the fourth metadata byte marks entries
# without filename hashes. This agrees with the root header's named
# count and is also how the manifest stays compact for unnamed FDIDs.
has_name_hash = (block_meta[3] & 0x10) == 0
end = hash_pos + (count * 8 if has_name_hash else 0)
if end > len(data):
raise CascError("truncated TSFM block data")
fdid = -1
for index in range(count):
delta, = struct.unpack_from("<I", data, delta_pos + index * 4)
fdid += delta + 1
ckey = data[ckey_pos + index * 16:ckey_pos + (index + 1) * 16]
values = mapping.setdefault(fdid, [])
if ckey not in values:
values.append(ckey)
seen_total += count
if has_name_hash:
seen_named += count
blocks += 1
pos = end
if seen_total != total_files or seen_named != named_files:
raise CascError("TSFM record-count mismatch")
self.root_header = dict(header_size=header_size, version=version,
total_files=total_files, named_files=named_files,
flags=flags, blocks=blocks)
self.root_map = mapping
def open_fdid(self, fdid: int) -> bytes:
if not self.root_map:
self.load_root()
ckeys = self.root_map.get(int(fdid))
if not ckeys:
raise CascError("FileDataID absent from root: %d" % fdid)
errors = []
for ckey in ckeys:
try:
return self.open_ckey(ckey)
except CascError as exc:
errors.append(str(exc))
raise CascError("no readable root entry for FDID %d: %s" % (fdid, "; ".join(errors)))
def open_name(self, path: str) -> bytes:
normalized = path.replace("\\", "/").lower()
fdid = self.fdids_by_name.get(normalized)
if fdid is None:
raise CascError("path absent from listfile: " + path)
return self.open_fdid(fdid)
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Check corrected Light band clone row IDs in loose files or an MPQ chain."""
import argparse
from pathlib import Path
from dbc_build import check_bands
from mpq_tools import Storm, copies_for, find_stormlib
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("path", type=Path, help="stage directory or client Data directory")
parser.add_argument("--stormlib", type=Path)
parser.add_argument("--stage", action="store_true", help="path is a loose stage tree")
args = parser.parse_args()
if args.stage:
root = args.path / "DBFilesClient"
integer = (root / "LightIntBand.dbc").read_bytes()
floating = (root / "LightFloatBand.dbc").read_bytes()
else:
storm = Storm(find_stormlib(args.stormlib))
payloads = []
for name in ("LightIntBand.dbc", "LightFloatBand.dbc"):
copies = copies_for(storm, args.path, "DBFilesClient\\" + name)
if not copies:
raise FileNotFoundError(name)
payloads.append(copies[-1][1])
integer, floating = payloads
check_bands(integer, floating)
print("PASS: clone rows use (P-1)*count + band + 1 and match params 23/21")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+200
View File
@@ -0,0 +1,200 @@
"""Winner-safe lighting DBC merge for the optional sky patch."""
from __future__ import annotations
import struct
from typing import Dict, List, Set, Tuple
REQUIRED = (
"Light.dbc",
"LightParams.dbc",
"LightIntBand.dbc",
"LightFloatBand.dbc",
"LightSkybox.dbc",
)
def parse_dbc(data: bytes, label: str) -> Tuple[List[List[int]], bytes, int]:
if len(data) < 20:
raise ValueError("truncated DBC: %s" % label)
magic, count, fields, record_size, string_size = struct.unpack_from("<4s4I", data)
if magic != b"WDBC" or record_size != fields * 4:
raise ValueError("invalid WDBC schema: %s" % label)
expected = 20 + count * record_size + string_size
if len(data) != expected:
raise ValueError("invalid WDBC length: %s" % label)
records = [
list(struct.unpack_from("<%dI" % fields, data, 20 + i * record_size))
for i in range(count)
]
return records, data[20 + count * record_size :], fields
def pack_dbc(records: List[List[int]], strings: bytes, fields: int) -> bytes:
if any(len(record) != fields for record in records):
raise ValueError("inconsistent DBC record width")
header = struct.pack("<4s4I", b"WDBC", len(records), fields, fields * 4, len(strings))
body = b"".join(struct.pack("<%dI" % fields, *record) for record in records)
return header + body + strings
def indexed(records: List[List[int]], label: str) -> Dict[int, List[int]]:
result = {record[0]: record for record in records}
if len(result) != len(records):
raise ValueError("duplicate row ID in %s" % label)
return result
def band_row_id(param: int, band: int, per: int) -> int:
return (param - 1) * per + band + 1
def append_string(block: bytearray, value: str) -> int:
encoded = value.encode("utf-8") + b"\0"
start = 0
while True:
offset = block.find(encoded, start)
if offset < 0:
offset = len(block)
block.extend(encoded)
return offset
if offset == 0 or block[offset - 1] == 0:
return offset
start = offset + 1
def build_merged(winners: Dict[str, bytes]) -> Tuple[Dict[str, bytes], Dict[str, object]]:
missing = [name for name in REQUIRED if name not in winners]
if missing:
raise ValueError("missing winning source DBCs: %s" % ", ".join(missing))
light, light_strings, light_fields = parse_dbc(winners["Light.dbc"], "Light")
params, params_strings, params_fields = parse_dbc(
winners["LightParams.dbc"], "LightParams"
)
ints, int_strings, int_fields = parse_dbc(
winners["LightIntBand.dbc"], "LightIntBand"
)
floats, float_strings, float_fields = parse_dbc(
winners["LightFloatBand.dbc"], "LightFloatBand"
)
skies, sky_strings, sky_fields = parse_dbc(
winners["LightSkybox.dbc"], "LightSkybox"
)
if (light_fields, params_fields, int_fields, float_fields, sky_fields) != (
12, 9, 34, 34, 2
):
raise ValueError("unexpected source lighting schema; use an unmodified 1.12 DBC chain")
if any(block != b"\0" for block in (
light_strings, params_strings, int_strings, float_strings
)):
raise ValueError("numeric lighting DBC has a non-empty string block")
light_by_id = indexed(light, "Light")
params_by_id = indexed(params, "LightParams")
int_by_id = indexed(ints, "LightIntBand")
float_by_id = indexed(floats, "LightFloatBand")
targets = {20: 1139, 539: 1139, 6: 1140, 65: 1140, 67: 1140}
expected = {20: 23, 539: 23, 6: 21, 65: 21, 67: 21}
if light_by_id.get(14, [None] * 8)[7] != 16:
raise ValueError("Light row 14 is not on expected Swamp of Sorrows param 16")
for row_id, new_param in targets.items():
if row_id not in light_by_id or light_by_id[row_id][7] != expected[row_id]:
raise ValueError("unexpected Light[%d].Clear" % row_id)
light_by_id[row_id][7] = new_param
start_param = max(
max(params_by_id),
(max(int_by_id) + 17) // 18,
(max(float_by_id) + 5) // 6,
) + 1
if start_param != 1139:
raise ValueError(
"winner-derived clone start is %d, expected 1139; refusing an unsafe merge"
% start_param
)
for source_param, new_param, skybox_id in ((23, 1139, 8), (21, 1140, 9)):
clone = params_by_id[source_param][:]
clone[0] = new_param
clone[2] = skybox_id
params.append(clone)
for per, source, destination in (
(18, int_by_id, ints),
(6, float_by_id, floats),
):
for band in range(per):
source_id = band_row_id(source_param, band, per)
target_id = band_row_id(new_param, band, per)
if source_id not in source or target_id in source:
raise ValueError("unsafe band clone %d -> %d" % (source_id, target_id))
row = source[source_id][:]
row[0] = target_id
destination.append(row)
if params_by_id.get(269, [None, None, None])[2] != 0:
raise ValueError("unexpected LightParams[269].LightSkybox")
params_by_id[269][2] = 10
strings = bytearray(sky_strings)
extended = [[row[0], row[1], 0, 0] for row in skies]
specs = (
(8, r"Environments\Stars\10can_volcanicsky01.mdx", 15, ""),
(9, r"Environments\Stars\bonewastesskybox.mdx", 4, ""),
(
10,
r"Environments\Stars\8drk_darkshoreelune_sky01.mdx",
2,
r"Environments\Stars\hyjal_skybox.mdx",
),
)
for row_id, model, flags, celestial in specs:
extended.append(
[row_id, append_string(strings, model), flags, append_string(strings, celestial)]
)
light.sort(key=lambda row: row[0])
params.sort(key=lambda row: row[0])
ints.sort(key=lambda row: row[0])
floats.sort(key=lambda row: row[0])
extended.sort(key=lambda row: row[0])
outputs = {
"Light.dbc": pack_dbc(light, light_strings, 12),
"LightParams.dbc": pack_dbc(params, params_strings, 9),
"LightIntBand.dbc": pack_dbc(ints, int_strings, 34),
"LightFloatBand.dbc": pack_dbc(floats, float_strings, 34),
"LightSkybox.dbc": pack_dbc(extended, bytes(strings), 4),
}
report = {
"source_rows": {
name: len(parse_dbc(winners[name], name)[0]) for name in REQUIRED
},
"output_rows": {
name: len(parse_dbc(payload, name)[0]) for name, payload in outputs.items()
},
"changed_light_rows": sorted(targets),
"light_14_clear": 16,
"clone_params": [1139, 1140],
}
return outputs, report
def check_bands(int_payload: bytes, float_payload: bytes) -> None:
for label, payload, per in (
("LightIntBand", int_payload, 18),
("LightFloatBand", float_payload, 6),
):
records, _, _ = parse_dbc(payload, label)
rows = indexed(records, label)
for source_param, target_param in ((23, 1139), (21, 1140)):
for band in range(per):
source_id = band_row_id(source_param, band, per)
target_id = band_row_id(target_param, band, per)
if target_id not in rows or rows[source_id][1:] != rows[target_id][1:]:
raise ValueError("%s clone mismatch at row %d" % (label, target_id))
top = band_row_id(1140, per - 1, per)
stray = [row_id for row_id in rows if row_id > top]
if stray:
raise ValueError("%s has rows above param 1140 span" % label)
+243
View File
@@ -0,0 +1,243 @@
"""Small StormLib wrapper used by the optional-data builder.
StormLib is loaded from the user's machine. This module never writes to a
client installation: candidates are copied or created below the requested
output directory and are verified after compaction.
"""
from __future__ import annotations
import ctypes
import os
from pathlib import Path
import re
import shutil
import stat
import tempfile
from typing import Dict, Iterable, List, Optional, Tuple
MPQ_OPEN_READ_ONLY = 0x00000100
MPQ_FILE_COMPRESS = 0x00000200
MPQ_FILE_REPLACEEXISTING = 0x80000000
MPQ_COMPRESSION_ZLIB = 0x02
MAX_FILE_COUNT = 8192
def find_stormlib(explicit: Optional[Path]) -> Path:
candidates = []
if explicit:
candidates.append(Path(explicit))
env = os.environ.get("STORMLIB_PATH")
if env:
candidates.append(Path(env))
candidates.extend(
[
Path.cwd() / "StormLib.dll",
Path(__file__).resolve().parent / "StormLib.dll",
]
)
for path in candidates:
if path.is_file():
return path.resolve()
raise FileNotFoundError(
"StormLib.dll was not found; pass --stormlib or set STORMLIB_PATH"
)
class Storm:
def __init__(self, library: Path):
if os.name != "nt":
raise OSError("this StormLib ctypes wrapper currently supports Windows")
self.library = Path(library)
self.dll = ctypes.WinDLL(str(self.library), use_last_error=True)
handle = ctypes.c_void_p
signatures = {
"SFileOpenArchive": (
ctypes.c_bool,
[ctypes.c_wchar_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.POINTER(handle)],
),
"SFileCreateArchive": (
ctypes.c_bool,
[ctypes.c_wchar_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.POINTER(handle)],
),
"SFileSetMaxFileCount": (ctypes.c_bool, [handle, ctypes.c_uint32]),
"SFileAddFileEx": (
ctypes.c_bool,
[handle, ctypes.c_wchar_p, ctypes.c_char_p, ctypes.c_uint32,
ctypes.c_uint32, ctypes.c_uint32],
),
"SFileCompactArchive": (
ctypes.c_bool,
[handle, ctypes.c_char_p, ctypes.c_bool],
),
"SFileOpenFileEx": (
ctypes.c_bool,
[handle, ctypes.c_char_p, ctypes.c_uint32, ctypes.POINTER(handle)],
),
"SFileGetFileSize": (
ctypes.c_uint32,
[handle, ctypes.POINTER(ctypes.c_uint32)],
),
"SFileReadFile": (
ctypes.c_bool,
[handle, ctypes.c_void_p, ctypes.c_uint32,
ctypes.POINTER(ctypes.c_uint32), ctypes.c_void_p],
),
"SFileCloseFile": (ctypes.c_bool, [handle]),
"SFileCloseArchive": (ctypes.c_bool, [handle]),
}
for name, (result, arguments) in signatures.items():
function = getattr(self.dll, name)
function.restype = result
function.argtypes = arguments
@staticmethod
def fail(message: str) -> None:
error = ctypes.get_last_error()
raise OSError(error, "%s (Win32 error %d)" % (message, error))
def open(self, archive: Path, flags: int = 0) -> ctypes.c_void_p:
handle = ctypes.c_void_p()
if not self.dll.SFileOpenArchive(str(archive), 0, flags, ctypes.byref(handle)):
self.fail("SFileOpenArchive failed: %s" % archive)
return handle
def create(self, archive: Path) -> ctypes.c_void_p:
handle = ctypes.c_void_p()
if not self.dll.SFileCreateArchive(
str(archive), 0, MAX_FILE_COUNT, ctypes.byref(handle)
):
self.fail("SFileCreateArchive failed: %s" % archive)
return handle
def close_archive(self, handle: ctypes.c_void_p) -> None:
if handle.value and not self.dll.SFileCloseArchive(handle):
self.fail("SFileCloseArchive failed")
def read(self, archive: Path, member: str) -> Optional[bytes]:
archive_handle = self.open(archive, MPQ_OPEN_READ_ONLY)
try:
file_handle = ctypes.c_void_p()
encoded = member.encode("ascii")
if not self.dll.SFileOpenFileEx(
archive_handle, encoded, 0, ctypes.byref(file_handle)
):
return None
try:
high = ctypes.c_uint32(0)
size = self.dll.SFileGetFileSize(file_handle, ctypes.byref(high))
if size == 0xFFFFFFFF or high.value:
self.fail("SFileGetFileSize failed: %s" % member)
buffer = ctypes.create_string_buffer(size)
received = ctypes.c_uint32(0)
if not self.dll.SFileReadFile(
file_handle, buffer, size, ctypes.byref(received), None
):
self.fail("SFileReadFile failed: %s" % member)
if received.value != size:
raise OSError("short MPQ read for %s" % member)
return buffer.raw[:size]
finally:
if file_handle.value and not self.dll.SFileCloseFile(file_handle):
self.fail("SFileCloseFile failed: %s" % member)
finally:
self.close_archive(archive_handle)
def pack(
self,
archive: Path,
files: Iterable[Tuple[Path, str]],
base_archive: Optional[Path] = None,
) -> None:
archive.parent.mkdir(parents=True, exist_ok=True)
if base_archive:
shutil.copy2(base_archive, archive)
os.chmod(archive, archive.stat().st_mode | stat.S_IWRITE)
handle = self.open(archive)
else:
handle = self.create(archive)
pairs = list(files)
listfile_temp = None
if not base_archive:
listfile_temp = tempfile.TemporaryDirectory(prefix="benilla-listfile-")
listfile_path = Path(listfile_temp.name) / "listfile.txt"
listfile_path.write_text(
"\r\n".join(member for _, member in pairs) + "\r\n(listfile)\r\n",
encoding="ascii",
)
pairs.append((listfile_path, "(listfile)"))
try:
if not self.dll.SFileSetMaxFileCount(handle, MAX_FILE_COUNT):
self.fail("SFileSetMaxFileCount failed")
for disk_path, member in pairs:
if not self.dll.SFileAddFileEx(
handle,
str(disk_path),
member.encode("ascii"),
MPQ_FILE_COMPRESS | MPQ_FILE_REPLACEEXISTING,
MPQ_COMPRESSION_ZLIB,
MPQ_COMPRESSION_ZLIB,
):
self.fail("SFileAddFileEx failed: %s" % member)
finally:
self.close_archive(handle)
handle = self.open(archive)
try:
if not self.dll.SFileCompactArchive(handle, None, False):
self.fail("SFileCompactArchive failed")
finally:
self.close_archive(handle)
for disk_path, member in pairs:
actual = self.read(archive, member)
if actual != disk_path.read_bytes():
raise RuntimeError("packed member failed verification: %s" % member)
if listfile_temp is not None:
listfile_temp.cleanup()
def mount_key(path: Path) -> Tuple[int, int, str]:
name = path.name.casefold()
if name == "patch.mpq":
return (1, 0, name)
numeric = re.fullmatch(r"patch-(\d+)\.mpq", name)
if numeric:
return (2, int(numeric.group(1)), name)
letter = re.fullmatch(r"patch-(.)\.mpq", name)
if letter:
return (3, ord(letter.group(1)), name)
return (0, 0, name)
def archives_in_mount_order(data_dir: Path) -> List[Path]:
paths = [
path for path in Path(data_dir).iterdir()
if path.is_file() and path.suffix.casefold() == ".mpq"
]
return sorted(paths, key=mount_key)
def copies_for(storm: Storm, data_dir: Path, member: str) -> List[Tuple[Path, bytes]]:
result = []
for archive in archives_in_mount_order(data_dir):
payload = storm.read(archive, member)
if payload is not None:
result.append((archive, payload))
return result
def winning_payloads(
storm: Storm, data_dir: Path, names: Iterable[str]
) -> Tuple[Dict[str, bytes], Dict[str, str]]:
payloads: Dict[str, bytes] = {}
winners: Dict[str, str] = {}
for name in names:
copies = copies_for(storm, data_dir, "DBFilesClient\\" + name)
if not copies:
raise FileNotFoundError("no mounted copy of DBFilesClient\\%s" % name)
archive, payload = copies[-1]
payloads[name] = payload
winners[name] = archive.name
return payloads, winners
@@ -0,0 +1,409 @@
"""Extract four modern sky models from local CASC and convert them to M2 v256.
pywowlib is an external dependency. The conversion and v256 compatibility
fixes here are original glue; no pywowlib source is redistributed.
"""
from __future__ import annotations
import importlib
import json
import os
from pathlib import Path
import shutil
import struct
import subprocess
import sys
from typing import Dict, Iterable, List, Tuple
PYWOWLIB_COMMIT = "55276dc5c2195da7fe136638a2a59716622f8c65"
MODELS = (
("blasted_lands", 130481, "bonewastesskybox.m2"),
("mount_hyjal_main", 2322316, "8drk_darkshoreelune_sky01.m2"),
("mount_hyjal_celestial", 317347, "hyjal_skybox.m2"),
("burning_steppes", 4505901, "10can_volcanicsky01.m2"),
)
def load_pywowlib(checkout: Path) -> Dict[str, object]:
checkout = Path(checkout).resolve()
package = checkout if checkout.name.casefold() == "pywowlib" else checkout / "pywowlib"
if not (package / "m2_file.py").is_file():
raise FileNotFoundError("--pywowlib must name a pywowlib checkout")
try:
head = subprocess.check_output(
["git", "-C", str(package), "rev-parse", "HEAD"], text=True
).strip()
except (OSError, subprocess.CalledProcessError) as error:
raise RuntimeError("the pywowlib checkout must retain its .git metadata") from error
if head != PYWOWLIB_COMMIT:
raise RuntimeError(
"pywowlib HEAD is %s; expected pinned commit %s" % (head, PYWOWLIB_COMMIT)
)
sys.path.insert(0, str(package.parent))
modules = {
"common": importlib.import_module("pywowlib.file_formats.wow_common_types"),
"types": importlib.import_module("pywowlib.io_utils.types"),
"m2_file": importlib.import_module("pywowlib.m2_file"),
"m2_format": importlib.import_module("pywowlib.file_formats.m2_format"),
"m2_chunks": importlib.import_module("pywowlib.file_formats.m2_chunks"),
"skin_format": importlib.import_module("pywowlib.file_formats.skin_format"),
}
return modules
def read_listfile(path: Path) -> Dict[int, str]:
result = {}
with Path(path).open("r", encoding="utf-8", errors="replace") as stream:
for line in stream:
raw_id, separator, name = line.rstrip("\r\n").partition(";")
if separator and raw_id.isdigit():
result[int(raw_id)] = name
return result
def source_version(path: Path) -> int:
data = path.read_bytes()[:16]
if data[:4] == b"MD20":
return struct.unpack_from("<I", data, 4)[0]
if data[:4] == b"MD21" and data[8:12] == b"MD20":
return struct.unpack_from("<I", data, 12)[0]
raise ValueError("unable to determine M2 version: %s" % path)
def expansion_for(version: int) -> int:
versions = {256: 0, 263: 1, 264: 2, 272: 3, 273: 5, 274: 6}
if version not in versions:
raise ValueError("unsupported M2 version %d" % version)
return versions[version]
def extract_sources(reader: object, listfile: Dict[int, str], root: Path, modules: Dict[str, object]) -> None:
M2File = modules["m2_file"].M2File
manager = modules["common"].M2VersionsManager()
for _, file_id, output_name in MODELS:
source_dir = root / str(file_id)
source_dir.mkdir(parents=True, exist_ok=True)
model_path = source_dir / output_name
model_path.write_bytes(reader.open_fdid(file_id))
version = source_version(model_path)
manager.set_m2_version(modules["common"].M2Versions.from_expansion_number(expansion_for(version)))
model = M2File(expansion_for(version), filepath=str(model_path))
dependencies = model.find_model_dependencies()
ids = set(int(value) for value in dependencies.skins)
for index, texture in enumerate(model.root.textures):
texture_id = int(getattr(texture, "fdid", 0) or 0)
if not texture_id and model.txid and index < len(model.txid.texture_ids):
texture_id = int(model.txid.texture_ids[index])
if texture.type == 0 and not texture.filename.value and texture_id:
ids.add(texture_id)
for dependency in sorted(ids):
game_path = listfile.get(dependency)
if not game_path:
raise KeyError("FileDataID %d is absent from the community listfile" % dependency)
local = source_dir / Path(game_path.replace("\\", "/")).name
local.write_bytes(reader.open_fdid(dependency))
def walk_objects(root: object) -> Iterable[object]:
seen = set()
stack = [root]
while stack:
value = stack.pop()
if id(value) in seen:
continue
seen.add(id(value))
yield value
if isinstance(value, (str, bytes, int, float, bool, type(None), tuple)):
continue
if isinstance(value, list):
stack.extend(value)
continue
values = getattr(value, "values", None)
if isinstance(values, list):
stack.extend(values)
attributes = getattr(value, "__dict__", None)
if attributes:
stack.extend(attributes.values())
def collect_tracks(root: object, track_base: type) -> List[object]:
return [value for value in walk_objects(root) if isinstance(value, track_base)]
def assign_sequence_times(root: object) -> None:
timestamp = 0
for sequence in root.sequences:
duration = int(getattr(sequence, "duration", 0) or 0)
sequence.start_timestamp = timestamp
sequence.end_timestamp = timestamp + duration
timestamp = sequence.end_timestamp + 1000
def denormalize_tracks(root: object, modules: Dict[str, object]) -> None:
common, types, fmt = modules["common"], modules["types"], modules["m2_format"]
M2Array, M2Range = common.M2Array, fmt.M2Range
sequences = list(root.sequences)
def array(value_type: type, values: List[object]) -> object:
result = M2Array(value_type)
result.values = list(values)
result.n_elements = len(result.values)
return result
for track in collect_tracks(root, fmt.M2TrackBase):
timestamp_arrays = track.timestamps.values
if not timestamp_arrays or not isinstance(timestamp_arrays[0], M2Array):
continue
has_values = hasattr(track, "values")
value_arrays = track.values.values if has_values else []
value_type = value_arrays[0].type if value_arrays else types.uint32
flat_times: List[int] = []
flat_values: List[object] = []
ranges: List[Tuple[int, int]] = []
if track.global_sequence >= 0:
flat_times = [int(value) for value in timestamp_arrays[0].values]
flat_values = list(value_arrays[0].values) if value_arrays else []
else:
last_value = None
for index, sequence in enumerate(sequences):
times = timestamp_arrays[index].values if index < len(timestamp_arrays) else []
values = value_arrays[index].values if index < len(value_arrays) else []
offset = len(flat_times)
if times:
flat_times.extend(int(value) + sequence.start_timestamp for value in times)
if has_values:
flat_values.extend(values)
last_value = values[-1]
ranges.append((offset, offset + len(times) - 1))
elif has_values and last_value is not None:
flat_times.append(sequence.start_timestamp)
flat_values.append(last_value)
ranges.append((offset, offset))
else:
ranges.append((offset, max(0, offset - 1)))
ranges.append((0, 0))
if has_values and flat_values and isinstance(flat_values[0], fmt.M2CompQuaternion):
converted = []
for value in flat_values:
ww, xx, yy, zz = value.to_quaternion()
converted.append((xx, yy, zz, ww))
flat_values = converted
value_type = types.quat
interpolation = M2Array(M2Range)
interpolation.values = []
for minimum, maximum in ranges:
item = M2Range()
item.minimum, item.maximum = minimum, maximum
interpolation.values.append(item)
interpolation.n_elements = len(interpolation.values)
track.interpolation_ranges = interpolation
track.timestamps = array(types.uint32, flat_times)
if has_values:
track.values = array(value_type, flat_values)
def stage_sources(shader_id: int, count: int) -> Tuple[int, ...]:
shader_id &= 0xFFFF
if count <= 1:
return (-1,) if shader_id & 0x80 else ((1,) if shader_id & 0x4000 else (0,))
first = -1 if shader_id & 0x80 else 0
second = -1 if shader_id & 0x8 else (1 if shader_id & 0x4000 else 0)
return (first, second)
def find_or_append(table: List[int], values: Tuple[int, ...]) -> int:
for index in range(len(table) - len(values) + 1):
if tuple(table[index : index + len(values)]) == values:
return index
table.extend(values)
return len(table) - len(values)
def fix_texture_units(model: object) -> None:
root = model.root
coordinates = [0, 1]
weights = list(root.transparency_lookup_table.values)
transforms = list(root.texture_transforms_lookup_table.values)
textures = list(root.texture_lookup_table.values)
pair_cache = {}
for skin in model.skins:
for unit in skin.texture_units.values:
count = int(unit.texture_count)
if count not in (1, 2):
raise ValueError("unsupported textureCount %d" % count)
unit.texture_coord_combo_index = find_or_append(
coordinates, stage_sources(unit.shader_id, count)
)
for stage in range(count):
tex = unit.texture_combo_index + stage
xform = unit.texture_transform_combo_index + stage
if tex >= len(textures) or textures[tex] >= len(root.textures.values):
raise ValueError("texture combo is out of range")
if xform >= len(transforms) or transforms[xform] >= len(root.texture_transforms.values):
raise ValueError("texture transform combo is out of range")
weight = unit.texture_weight_combo_index
if weight >= len(weights) or weights[weight] >= len(root.texture_weights.values):
raise ValueError("texture weight combo is out of range")
if count == 2:
track = weights[weight]
if track not in pair_cache:
pair_cache[track] = find_or_append(weights, (track, track))
unit.texture_weight_combo_index = pair_cache[track]
root.tex_unit_lookup_table.values = coordinates
root.tex_unit_lookup_table.n_elements = len(coordinates)
root.transparency_lookup_table.values = weights
root.transparency_lookup_table.n_elements = len(weights)
def make_classic(model: object, modules: Dict[str, object]) -> None:
common, fmt, chunks = modules["common"], modules["m2_format"], modules["m2_chunks"]
source_root = model.root
assign_sequence_times(source_root)
denormalize_tracks(source_root, modules)
for sequence in source_root.sequences:
if not hasattr(sequence, "blend_time"):
sequence.blend_time = max(
int(getattr(sequence, "blend_time_in", 0)),
int(getattr(sequence, "blend_time_out", 0)),
)
common.M2VersionsManager().set_m2_version(common.M2Versions.CLASSIC)
root = chunks.MD20()
for name in list(root.__dict__):
if name in source_root.__dict__ and name not in {"magic", "version", "m2_version", "_size"}:
setattr(root, name, getattr(source_root, name))
model.root = root
model.version = common.M2Versions.CLASSIC
for value in walk_objects(root):
if isinstance(value, fmt.M2TrackBase) and not hasattr(value, "interpolation_ranges"):
value.interpolation_ranges = common.M2Array(fmt.M2Range)
if hasattr(value, "m2_version"):
value.m2_version = common.M2Versions.CLASSIC
if value.__class__.__name__ == "M2SkinProfile":
value._size = 44
for skin in model.skins:
for value in walk_objects(skin):
if hasattr(value, "m2_version"):
value.m2_version = common.M2Versions.CLASSIC
if value.__class__.__name__ == "M2SkinProfile":
value._size = 44
def write_classic(model: object, path: Path, modules: Dict[str, object]) -> None:
skin_class = modules["skin_format"].M2SkinProfile
bone_class = modules["m2_format"].M2CompBone
types = modules["types"]
original_skin_write = skin_class.write
original_skin_size = getattr(skin_class, "size", None)
original_bone_write = bone_class.write
def write_embedded(skin: object, stream: object) -> object:
position = stream.tell()
stream.write(b"\0" * 44)
stream.seek(position)
skin.vertex_indices.write(stream)
skin.triangle_indices.write(stream)
skin.bone_indices.write(stream)
skin.submeshes.write(stream)
skin.texture_units.write(stream)
types.uint32.write(stream, skin.bone_count_max)
return skin
def write_classic_bone(bone: object, stream: object) -> object:
types.int32.write(stream, bone.key_bone_id)
types.uint32.write(stream, bone.flags)
types.int16.write(stream, bone.parent_bone)
types.uint16.write(stream, bone.submesh_id)
bone.translation.write(stream)
bone.rotation.write(stream)
bone.scale.write(stream)
types.vec3D.write(stream, bone.pivot)
return bone
skin_class.write = write_embedded
skin_class.size = staticmethod(lambda: 44)
bone_class.write = write_classic_bone
try:
model.root.skin_profiles.values = list(model.skins)
model.root.skin_profiles.n_elements = len(model.skins)
model.root.num_skin_profiles = len(model.skins)
with path.open("wb") as stream:
model.root.write(stream)
stream.seek(0, 2)
padding = (16 - stream.tell() % 16) % 16
stream.write(b"\0" * padding)
finally:
skin_class.write = original_skin_write
if original_skin_size is None:
delattr(skin_class, "size")
else:
skin_class.size = original_skin_size
bone_class.write = original_bone_write
def convert_all(
source_root: Path,
output_root: Path,
listfile_path: Path,
modules: Dict[str, object],
) -> List[Dict[str, object]]:
listfile = read_listfile(listfile_path)
output_root.mkdir(parents=True, exist_ok=True)
result = []
for key, file_id, output_name in MODELS:
source_dir = source_root / str(file_id)
source_path = source_dir / output_name
version = source_version(source_path)
common = modules["common"]
common.M2VersionsManager().set_m2_version(
common.M2Versions.from_expansion_number(expansion_for(version))
)
model = modules["m2_file"].M2File(expansion_for(version), filepath=str(source_path))
dependencies = model.find_model_dependencies()
skins = []
for dependency in dependencies.skins:
name = listfile.get(int(dependency))
if not name:
raise KeyError("skin FileDataID %d missing from listfile" % dependency)
skins.append(str(source_dir / Path(name.replace("\\", "/")).name))
model.read_additional_files(skins, {})
textures = []
for index, texture in enumerate(model.root.textures):
file_id_value = int(getattr(texture, "fdid", 0) or 0)
if not file_id_value and model.txid and index < len(model.txid.texture_ids):
file_id_value = int(model.txid.texture_ids[index])
if texture.type == 0 and not texture.filename.value:
game_path = listfile.get(file_id_value)
if not game_path:
raise KeyError("texture FileDataID %d missing from listfile" % file_id_value)
texture.filename.value = game_path.replace("/", "\\")
local = source_dir / Path(game_path.replace("\\", "/")).name
target = output_root.joinpath(*game_path.replace("\\", "/").split("/"))
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(local, target)
textures.append(str(target.relative_to(output_root)).replace("/", "\\"))
model.root.global_flags &= 0x17
if hasattr(model.root, "texture_combiner_combos"):
model.root.texture_combiner_combos.values = []
model.root.texture_combiner_combos.n_elements = 0
for material in model.root.materials:
if int(material.blending_mode) > 6:
material.blending_mode = 2
fix_texture_units(model)
make_classic(model, modules)
target = output_root / "Environments" / "Stars" / output_name
target.parent.mkdir(parents=True, exist_ok=True)
write_classic(model, target, modules)
result.append(
{
"key": key,
"source_file_data_id": file_id,
"source_version": version,
"output": str(target),
"output_bytes": target.stat().st_size,
"skins_embedded": len(model.skins),
"textures": textures,
}
)
return result
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Verify every staged payload is the mounted winner in a candidate Data chain."""
from __future__ import annotations
import argparse
import hashlib
from pathlib import Path
from dbc_build import check_bands, indexed, parse_dbc
from mpq_tools import Storm, copies_for, find_stormlib
def digest(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def semantic_checks(payloads: dict[str, bytes]) -> None:
light, _, fields = parse_dbc(payloads["DBFilesClient\\Light.dbc"], "Light")
if fields != 12:
raise ValueError("Light.dbc is not 12 fields")
light_rows = indexed(light, "Light")
expected = {20: 1139, 539: 1139, 6: 1140, 65: 1140, 67: 1140, 14: 16}
for row_id, clear in expected.items():
if light_rows[row_id][7] != clear:
raise ValueError("Light[%d].Clear is not %d" % (row_id, clear))
params, _, fields = parse_dbc(
payloads["DBFilesClient\\LightParams.dbc"], "LightParams"
)
if fields != 9:
raise ValueError("LightParams.dbc is not 9 fields")
param_rows = indexed(params, "LightParams")
for row_id, skybox in ((1139, 8), (1140, 9), (269, 10)):
if param_rows[row_id][2] != skybox:
raise ValueError("LightParams[%d] skybox mismatch" % row_id)
if 1141 in param_rows:
raise ValueError("stray LightParams 1141")
check_bands(
payloads["DBFilesClient\\LightIntBand.dbc"],
payloads["DBFilesClient\\LightFloatBand.dbc"],
)
skies, strings, fields = parse_dbc(
payloads["DBFilesClient\\LightSkybox.dbc"], "LightSkybox"
)
if fields != 4 or not strings:
raise ValueError("LightSkybox.dbc is not the extended four-field layout")
sky_rows = indexed(skies, "LightSkybox")
for row_id in (8, 9, 10):
if row_id not in sky_rows:
raise ValueError("LightSkybox row %d is missing" % row_id)
grade, _, fields = parse_dbc(
payloads["DBFilesClient\\MonkeyZoneGrade.dbc"], "MonkeyZoneGrade"
)
if fields != 5 or len(grade) != 4:
raise ValueError("MonkeyZoneGrade must have four five-field rows")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("data_dir", type=Path)
parser.add_argument("--stage", type=Path, required=True)
parser.add_argument("--stormlib", type=Path)
parser.add_argument("--require-winner", default="patch-Z.mpq")
args = parser.parse_args()
storm = Storm(find_stormlib(args.stormlib))
expected = {
str(path.relative_to(args.stage)).replace("/", "\\"): path.read_bytes()
for path in sorted(args.stage.rglob("*"))
if path.is_file()
}
mounted = {}
failures = []
for member, wanted in expected.items():
copies = copies_for(storm, args.data_dir, member)
if not copies:
failures.append("missing %s" % member)
continue
archive, actual = copies[-1]
mounted[member] = actual
if args.require_winner.casefold() != "any" and (
archive.name.casefold() != args.require_winner.casefold()
):
failures.append("%s wins from %s" % (member, archive.name))
if actual != wanted:
failures.append(
"%s differs: %s != %s" % (member, digest(actual), digest(wanted))
)
required = {
"DBFilesClient\\Light.dbc",
"DBFilesClient\\LightParams.dbc",
"DBFilesClient\\LightIntBand.dbc",
"DBFilesClient\\LightFloatBand.dbc",
"DBFilesClient\\LightSkybox.dbc",
"DBFilesClient\\MonkeyZoneGrade.dbc",
}
if required <= set(mounted):
try:
semantic_checks(mounted)
except (KeyError, ValueError) as error:
failures.append("semantic check: %s" % error)
if failures:
print("FAIL")
for failure in failures:
print(" " + failure)
return 1
print("PASS: %d staged payloads are mounted winners" % len(expected))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+171
View File
@@ -0,0 +1,171 @@
"""Minimal WDC5 reader for dense modern WoW DB2 tables."""
from __future__ import annotations
import copy
import struct
class WdcError(Exception):
pass
def _bits_le(data: bytes, bit_offset: int, bit_count: int) -> int:
if bit_count == 0:
return 0
start = bit_offset // 8
shift = bit_offset & 7
count = (shift + bit_count + 7) // 8
value = int.from_bytes(data[start:start + count], "little") >> shift
return value & ((1 << bit_count) - 1)
class WDC5:
HEADER_OFFSET = 0x88
def __init__(self, data: bytes):
self.data = data
if data[:4] != b"WDC5" or len(data) < self.HEADER_OFFSET + 68:
raise WdcError("not a supported WDC5 table")
o = self.HEADER_OFFSET
(self.record_count, self.field_count, self.record_size,
self.string_table_size, self.table_hash, self.layout_hash,
self.min_id, self.max_id, self.locale) = struct.unpack_from("<9I", data, o)
self.flags, self.id_index = struct.unpack_from("<HH", data, o + 36)
(self.total_field_count, self.bitpacked_data_offset,
self.lookup_column_count, self.field_storage_info_size,
self.common_data_size, self.pallet_data_size,
self.section_count) = struct.unpack_from("<7I", data, o + 40)
if self.section_count != 1:
raise WdcError("only single-section WDC5 tables are supported")
at = o + 68
section = struct.unpack_from("<Q8I", data, at)
(self.tact_key_hash, self.file_offset, section_records,
self.section_string_size, self.offset_records_end,
self.id_list_size, self.relationship_data_size,
self.offset_map_id_count, self.copy_table_count) = section
if section_records != self.record_count:
raise WdcError("multi-section record count mismatch")
at += 40
self.field_structures = [struct.unpack_from("<hH", data, at + i * 4)
for i in range(self.total_field_count)]
at += self.total_field_count * 4
self.storage = [struct.unpack_from("<HH5I", data, at + i * 24)
for i in range(self.total_field_count)]
at += self.field_storage_info_size
self.pallet = data[at:at + self.pallet_data_size]
at += self.pallet_data_size
self.common = data[at:at + self.common_data_size]
at += self.common_data_size
if at != self.file_offset:
raise WdcError("WDC5 metadata/file-offset mismatch")
self.records_offset = self.file_offset
self.strings_offset = self.records_offset + self.record_count * self.record_size
after_strings = self.strings_offset + self.section_string_size
self.ids_offset = after_strings
after_ids = self.ids_offset + self.id_list_size
self.copy_offset = after_ids + self.relationship_data_size
poff = coff = 0
self.aux_offsets = []
for _field_offset, _field_size, additional, kind, _a, _b, _c in self.storage:
if kind in (3, 4):
self.aux_offsets.append((poff, None))
poff += additional
elif kind == 2:
self.aux_offsets.append((None, coff))
coff += additional
else:
self.aux_offsets.append((None, None))
if poff != self.pallet_data_size or coff != self.common_data_size:
raise WdcError("WDC5 auxiliary-data size mismatch")
if self.id_list_size:
if self.id_list_size != self.record_count * 4:
raise WdcError("unexpected WDC5 ID-list size")
self.ids = list(struct.unpack_from("<%dI" % self.record_count,
data, self.ids_offset))
else:
self.ids = [None] * self.record_count
self.rows = {}
self.row_ids = []
for index in range(self.record_count):
recpos = self.records_offset + index * self.record_size
record = data[recpos:recpos + self.record_size]
values = [self._value(index, record, i) for i in range(self.total_field_count)]
row_id = self.ids[index]
if row_id is None:
row_id = values[self.id_index]
self.rows[row_id] = {"id": row_id, "values": values,
"record_offset": recpos, "copy_of": None}
self.row_ids.append(row_id)
self.relationship_by_index = {}
if self.relationship_data_size:
relpos = after_ids
if self.relationship_data_size < 12:
raise WdcError("short WDC5 relationship block")
count, self.relationship_min_id, self.relationship_max_id = \
struct.unpack_from("<3I", data, relpos)
if self.relationship_data_size != 12 + count * 8:
raise WdcError("unexpected WDC5 relationship-block size")
for i in range(count):
foreign_id, record_index = struct.unpack_from("<2I", data, relpos + 12 + i * 8)
if record_index >= self.record_count:
raise WdcError("WDC5 relationship record index is out of bounds")
self.relationship_by_index[record_index] = foreign_id
self.rows[self.row_ids[record_index]]["parent_id"] = foreign_id
for i in range(self.copy_table_count):
new_id, source_id = struct.unpack_from("<II", data, self.copy_offset + i * 8)
source = self.rows.get(source_id)
if source is None:
raise WdcError("copy-table source ID is absent")
row = copy.deepcopy(source)
row["id"] = new_id
row["copy_of"] = source_id
self.rows[new_id] = row
def _value(self, row_index: int, record: bytes, field: int):
bit_offset, bit_size, additional, kind, a, b, c = self.storage[field]
raw = _bits_le(record, bit_offset, bit_size)
if kind in (0, 1):
return raw
if kind == 5:
if bit_size and raw & (1 << (bit_size - 1)):
raw -= 1 << bit_size
return raw
if kind == 2:
default = a
_, coff = self.aux_offsets[field]
block = self.common[coff:coff + additional]
row_id = self.ids[row_index]
for pos in range(0, len(block), 8):
key, value = struct.unpack_from("<II", block, pos)
if key == row_id:
return value
return default
if kind == 3:
poff, _ = self.aux_offsets[field]
if raw * 4 + 4 > additional:
raise WdcError("palette index out of bounds")
return struct.unpack_from("<I", self.pallet, poff + raw * 4)[0]
if kind == 4:
width = c
poff, _ = self.aux_offsets[field]
base = poff + raw * width * 4
if base + width * 4 > poff + additional:
raise WdcError("palette-array index out of bounds")
return struct.unpack_from("<%dI" % width, self.pallet, base)
raise WdcError("unsupported WDC5 storage type %d" % kind)
def string(self, row, field: int) -> str:
relative = row["values"][field]
start = row["record_offset"] + relative
if start < self.strings_offset or start >= self.strings_offset + self.section_string_size:
return ""
end = self.data.find(b"\0", start, self.strings_offset + self.section_string_size)
if end < 0:
end = self.strings_offset + self.section_string_size
return self.data[start:end].decode("utf-8", errors="replace")
@staticmethod
def as_float(value: int) -> float:
return struct.unpack("<f", struct.pack("<I", value & 0xFFFFFFFF))[0]
@@ -0,0 +1,218 @@
"""Moderated WoW Forever LightData colour import.
The modern table is read directly from the user's CASC installation. Only
colour tracks are imported; fog distances, shadow opacity, and modern-only fog
fields remain untouched.
"""
from __future__ import annotations
import bisect
import math
import struct
from typing import Dict, Iterable, List, Tuple
from dbc_build import pack_dbc, parse_dbc
DAY = 2880
ANCHORS = (0, 720, 1440, 2160)
TARGET_PARAMS = (
2, 3, 4, 10, 12, 13, 14, 15, 17, 21, 22, 23,
24, 25, 192, 193, 206, 213, 269, 271, 454, 456, 494, 497,
)
DUSKWOOD_LOCAL = (14, 15, 494)
BLENDED_BANDS = tuple(index for index in range(18) if index != 8)
def unpack_band(row: List[int]) -> Tuple[List[int], List[int]]:
count = row[1]
if count > 16:
raise ValueError("LightIntBand row has more than 16 keys")
return row[2 : 2 + count], row[18 : 18 + count]
def make_band(record_id: int, times: List[int], values: List[int]) -> List[int]:
if len(times) != len(values) or len(times) > 16:
raise ValueError("invalid band key arrays")
return [record_id, len(times)] + times + [0] * (16 - len(times)) + values + [0] * (
16 - len(values)
)
def rgb_tuple(value: int) -> Tuple[float, float, float]:
return (
((value >> 16) & 255) / 255.0,
((value >> 8) & 255) / 255.0,
(value & 255) / 255.0,
)
def rgb_int(rgb: Iterable[float]) -> int:
channels = [max(0, min(255, round(value * 255.0))) for value in rgb]
return (channels[0] << 16) | (channels[1] << 8) | channels[2]
def sample(row: List[int], time: int) -> int:
times, values = unpack_band(row)
if not times:
return 0
if len(times) == 1:
return values[0] & 0xFFFFFF
time %= DAY
if time < times[0] or time >= times[-1]:
left_t, right_t = times[-1], times[0] + DAY
left_v, right_v = values[-1], values[0]
query = time + DAY if time < times[0] else time
else:
right = bisect.bisect_right(times, time)
left_t, right_t = times[right - 1], times[right]
left_v, right_v = values[right - 1], values[right]
query = time
amount = (query - left_t) / (right_t - left_t)
return rgb_int(
a + (b - a) * amount for a, b in zip(rgb_tuple(left_v), rgb_tuple(right_v))
)
def srgb_to_linear(value: float) -> float:
return value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4
def linear_to_srgb(value: float) -> float:
value = max(0.0, value)
return 12.92 * value if value <= 0.0031308 else 1.055 * value ** (1 / 2.4) - 0.055
def rgb_to_oklab(rgb: Tuple[float, float, float]) -> Tuple[float, float, float]:
red, green, blue = (srgb_to_linear(value) for value in rgb)
ll = 0.4122214708 * red + 0.5363325363 * green + 0.0514459929 * blue
mm = 0.2119034982 * red + 0.6806995451 * green + 0.1073969566 * blue
ss = 0.0883024619 * red + 0.2817188376 * green + 0.6299787005 * blue
ll, mm, ss = (
math.copysign(abs(ll) ** (1 / 3), ll),
math.copysign(abs(mm) ** (1 / 3), mm),
math.copysign(abs(ss) ** (1 / 3), ss),
)
return (
0.2104542553 * ll + 0.7936177850 * mm - 0.0040720468 * ss,
1.9779984951 * ll - 2.4285922050 * mm + 0.4505937099 * ss,
0.0259040371 * ll + 0.7827717662 * mm - 0.8086757660 * ss,
)
def oklab_to_rgb(lab: Tuple[float, float, float]) -> Tuple[float, float, float]:
light, aa, bb = lab
ll = light + 0.3963377774 * aa + 0.2158037573 * bb
mm = light - 0.1055613458 * aa - 0.0638541728 * bb
ss = light - 0.0894841775 * aa - 1.2914855480 * bb
ll, mm, ss = ll ** 3, mm ** 3, ss ** 3
red = 4.0767416621 * ll - 3.3077115913 * mm + 0.2309699292 * ss
green = -1.2684380046 * ll + 2.6097574011 * mm - 0.3413193965 * ss
blue = -0.0041960863 * ll - 0.7034186147 * mm + 1.7076147010 * ss
return tuple(
max(0.0, min(1.0, linear_to_srgb(value))) for value in (red, green, blue)
)
def chroma_hue(lab: Tuple[float, float, float]) -> Tuple[float, float]:
_, aa, bb = lab
return math.hypot(aa, bb), math.degrees(math.atan2(bb, aa)) % 360.0
def clamp_duskwood(
base: Tuple[float, float, float], mixed: Tuple[float, float, float]
) -> Tuple[float, float, float]:
base_chroma, base_hue = chroma_hue(base)
mixed_chroma, mixed_hue = chroma_hue(mixed)
mixed_chroma = min(mixed_chroma, base_chroma + 0.015)
if mixed_chroma < 1e-8:
return (mixed[0], 0.0, 0.0)
if base_chroma >= 0.006:
shift = (mixed_hue - base_hue + 180.0) % 360.0 - 180.0
hue = base_hue + max(-12.0, min(12.0, shift))
else:
hue = 155.0
radians = math.radians(hue)
return (
mixed[0],
mixed_chroma * math.cos(radians),
mixed_chroma * math.sin(radians),
)
def blend(base_value: int, wf_value: int, amount: float, duskwood: bool) -> int:
if base_value == wf_value or amount <= 0.0:
return base_value
base = rgb_to_oklab(rgb_tuple(base_value))
modern = rgb_to_oklab(rgb_tuple(wf_value))
mixed = tuple(a + (b - a) * amount for a, b in zip(base, modern))
if duskwood:
mixed = clamp_duskwood(base, mixed)
return rgb_int(oklab_to_rgb(mixed))
def strength(time: int, night: float, day: float) -> float:
day_weight = (1.0 - math.cos(2.0 * math.pi * (time % DAY) / DAY)) * 0.5
return night + (day - night) * day_weight
def modern_field_for_band(band: int) -> int:
if band <= 7:
return 3 + band
if band == 8:
return 20
return band + 2
def apply_wf_colours(base_payload: bytes, light_data: object) -> Tuple[bytes, int]:
records, strings, fields = parse_dbc(base_payload, "LightIntBand")
if fields != 34 or strings != b"\0":
raise ValueError("unexpected LightIntBand layout")
by_id = {row[0]: row for row in records}
modern: Dict[int, List[object]] = {}
for row in light_data.rows.values():
values = row["values"]
param = row.get("parent_id", values[1])
if param in TARGET_PARAMS:
modern.setdefault(param, []).append(row)
missing = [param for param in TARGET_PARAMS if not modern.get(param)]
if missing:
raise ValueError("modern LightData is missing target params: %s" % missing)
changed = 0
for param in TARGET_PARAMS:
rows = sorted(
modern[param], key=lambda row: (row["values"][2] & 0xFFFF, row["id"])
)
if len(rows) > 16:
step = (len(rows) - 1) / 15.0
rows = [rows[round(index * step)] for index in range(16)]
for band in BLENDED_BANDS:
record_id = (param - 1) * 18 + band + 1
if record_id not in by_id:
raise ValueError("missing LightIntBand row %d" % record_id)
base_row = by_id[record_id]
modern_times = [row["values"][2] & 0xFFFF for row in rows]
field = modern_field_for_band(band)
modern_values = [row["values"][field] & 0xFFFFFF for row in rows]
modern_row = make_band(record_id, modern_times, modern_values)
base_times, _ = unpack_band(base_row)
times = sorted(set(base_times) | set(modern_times) | set(ANCHORS))
if len(times) > 16:
raise ValueError("moderated row %d needs more than 16 keys" % record_id)
duskwood = param in DUSKWOOD_LOCAL
night, day = ((0.30, 0.15) if duskwood else (0.50, 0.25))
values = [
blend(
sample(base_row, time),
sample(modern_row, time),
strength(time, night, day),
duskwood,
)
for time in times
]
if any(sample(base_row, time) != value for time, value in zip(times, values)):
by_id[record_id][:] = make_band(record_id, times, values)
changed += 1
return pack_dbc(records, strings, fields), changed
+2
View File
@@ -68,6 +68,8 @@ at any IP or hostname, appending the auth port if yours is remapped
(`WOW_HOST=play.example.com:5000`). Credentials go in at the login screen, or set `WOW_USER` /
`WOW_PASS` to skip it.
Optional, locally built skybox and colour-grading data: [`Optional/sky-and-grading/`](Optional/sky-and-grading/README.md).
## Contributing
Issues and pull requests are open. [`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) says what gets