Files
benilla-octo/Optional/sky-and-grading/tools/wf_colours.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

219 lines
7.8 KiB
Python

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