Files
benilla-octo/Optional/sky-and-grading/tools/sky_convert.py
T
pkuzicandGPT-5.6 Sol (Codex) 67388625ec MONKEY (optional): add local sky and grading patch builder
Build Blizzard-derived sky assets only from the user's own CASC install, merge lighting DBCs from mounted winners, and pack a preserved patch-Z candidate. Include bilingual install and recovery guidance plus project-owned grading data.

Verified all 46 default stage payloads byte-identical to the reviewed stage; moderated WoW Forever LightIntBand byte-identical; band and mounted-chain checks pass on a hard-linked client copy. No Cargo command run.

Co-Authored-By: GPT-5.6 Sol (Codex)
2026-09-26 15:19:25 +01:00

410 lines
17 KiB
Python

"""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