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

414 lines
18 KiB
Python

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