taxi builder
This commit is contained in:
@@ -0,0 +1,958 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
moonwell-taxi: merge custom_taxi_* DB rows with vanilla TaxiNodes /
|
||||
TaxiPath / TaxiPathNode DBCs, write server and client copies, and
|
||||
sha256-verify they match.
|
||||
|
||||
The DBC binary format used by 3.3.5a is:
|
||||
|
||||
20-byte header:
|
||||
magic char[4] = b'WDBC'
|
||||
record_count uint32
|
||||
field_count uint32
|
||||
record_size uint32 (bytes per record)
|
||||
sb_size uint32 (string block size)
|
||||
record_count * record_size bytes of records
|
||||
sb_size bytes of NUL-terminated strings (the string block)
|
||||
|
||||
Field types are not encoded in the file; the schema is implicit.
|
||||
For our three tables the layouts are:
|
||||
|
||||
TaxiNodes (24 fields, 96 B/record):
|
||||
ID uint32
|
||||
ContinentID uint32
|
||||
Pos_X float
|
||||
Pos_Y float
|
||||
Pos_Z float
|
||||
Name_lang[16] uint32 string offsets
|
||||
Name_lang_mask uint32
|
||||
MountCreatureID[2] uint32 (Horde, Alliance)
|
||||
|
||||
TaxiPath (4 fields, 16 B/record):
|
||||
ID, FromTaxiNode, ToTaxiNode, Cost (all uint32)
|
||||
|
||||
TaxiPathNode (11 fields, 44 B/record):
|
||||
ID, PathID, NodeIndex, ContinentID uint32
|
||||
Loc_X, Loc_Y, Loc_Z float
|
||||
Flags, Delay, ArrivalEventID, DepartureEventID uint32
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
DBC_MAGIC = b"WDBC"
|
||||
DBC_HEADER_FMT = "<4sIIII"
|
||||
DBC_HEADER_SIZE = 20
|
||||
|
||||
TAXI_NODES_FIELDS = 24
|
||||
TAXI_NODES_RECSIZE = TAXI_NODES_FIELDS * 4 # 96
|
||||
TAXI_PATH_FIELDS = 4
|
||||
TAXI_PATH_RECSIZE = TAXI_PATH_FIELDS * 4 # 16
|
||||
TAXI_PATH_NODE_FIELDS = 11
|
||||
TAXI_PATH_NODE_RECSIZE = TAXI_PATH_NODE_FIELDS * 4 # 44
|
||||
|
||||
# Vanilla DBC files we merge into. Filenames must match WoW's case.
|
||||
DBC_FILES = ("TaxiNodes.dbc", "TaxiPath.dbc", "TaxiPathNode.dbc")
|
||||
CLIENT_MPQ_PATHS = tuple(f"DBFilesClient\\{name}" for name in DBC_FILES)
|
||||
|
||||
MPQ_MAGIC = b"MPQ\x1A"
|
||||
MPQ_HEADER_FMT = "<4sIIHHIIII"
|
||||
MPQ_HEADER_SIZE = 32
|
||||
MPQ_HASH_ENTRY_DELETED = 0xFFFFFFFE
|
||||
MPQ_HASH_ENTRY_EMPTY = 0xFFFFFFFF
|
||||
MPQ_FILE_EXISTS = 0x80000000
|
||||
MPQ_FILE_SINGLE_UNIT = 0x01000000
|
||||
|
||||
|
||||
def _build_mpq_crypt_table() -> list:
|
||||
crypt_table = [0] * 0x500
|
||||
seed = 0x00100001
|
||||
for index1 in range(0x100):
|
||||
index2 = index1
|
||||
for _ in range(5):
|
||||
seed = (seed * 125 + 3) % 0x2AAAAB
|
||||
temp1 = (seed & 0xFFFF) << 16
|
||||
seed = (seed * 125 + 3) % 0x2AAAAB
|
||||
temp2 = seed & 0xFFFF
|
||||
crypt_table[index2] = (temp1 | temp2) & 0xFFFFFFFF
|
||||
index2 += 0x100
|
||||
return crypt_table
|
||||
|
||||
|
||||
MPQ_CRYPT_TABLE = _build_mpq_crypt_table()
|
||||
|
||||
|
||||
def mpq_hash_string(name: str, hash_type: int) -> int:
|
||||
seed1 = 0x7FED7FED
|
||||
seed2 = 0xEEEEEEEE
|
||||
normalized = name.replace("/", "\\").upper()
|
||||
for ch in normalized.encode("ascii"):
|
||||
value = MPQ_CRYPT_TABLE[(hash_type << 8) + ch]
|
||||
seed1 = (value ^ (seed1 + seed2)) & 0xFFFFFFFF
|
||||
seed2 = (ch + seed1 + seed2 + ((seed2 << 5) & 0xFFFFFFFF) + 3) & 0xFFFFFFFF
|
||||
return seed1
|
||||
|
||||
|
||||
def mpq_encrypt_words(words: list, key: int) -> bytes:
|
||||
seed1 = key
|
||||
seed2 = 0xEEEEEEEE
|
||||
out = []
|
||||
for word in words:
|
||||
word &= 0xFFFFFFFF
|
||||
seed2 = (seed2 + MPQ_CRYPT_TABLE[0x400 + (seed1 & 0xFF)]) & 0xFFFFFFFF
|
||||
encrypted = (word ^ (seed1 + seed2)) & 0xFFFFFFFF
|
||||
seed1 = (((~seed1 << 21) & 0xFFFFFFFF) + 0x11111111 + (seed1 >> 11)) & 0xFFFFFFFF
|
||||
seed2 = (encrypted + seed2 + ((seed2 << 5) & 0xFFFFFFFF) + 3) & 0xFFFFFFFF
|
||||
out.append(encrypted)
|
||||
return struct.pack(f"<{len(out)}I", *out)
|
||||
|
||||
|
||||
def mpq_decrypt_words(data: bytes, key: int) -> list:
|
||||
if len(data) % 4:
|
||||
raise ValueError("encrypted MPQ table size is not uint32-aligned")
|
||||
seed1 = key
|
||||
seed2 = 0xEEEEEEEE
|
||||
words = struct.unpack(f"<{len(data) // 4}I", data)
|
||||
out = []
|
||||
for encrypted in words:
|
||||
seed2 = (seed2 + MPQ_CRYPT_TABLE[0x400 + (seed1 & 0xFF)]) & 0xFFFFFFFF
|
||||
plain = (encrypted ^ (seed1 + seed2)) & 0xFFFFFFFF
|
||||
seed1 = (((~seed1 << 21) & 0xFFFFFFFF) + 0x11111111 + (seed1 >> 11)) & 0xFFFFFFFF
|
||||
seed2 = (encrypted + seed2 + ((seed2 << 5) & 0xFFFFFFFF) + 3) & 0xFFFFFFFF
|
||||
out.append(plain)
|
||||
return out
|
||||
|
||||
|
||||
def next_power_of_two(value: int) -> int:
|
||||
n = 1
|
||||
while n < value:
|
||||
n <<= 1
|
||||
return n
|
||||
|
||||
|
||||
def build_mpq(files: dict, output_file: Path) -> None:
|
||||
"""Write a minimal MPQ v1 archive with uncompressed single-unit files."""
|
||||
names = list(files)
|
||||
hash_table_size = max(8, next_power_of_two(len(names) * 2))
|
||||
hash_entries = [[0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF] for _ in range(hash_table_size)]
|
||||
block_entries = []
|
||||
|
||||
cursor = MPQ_HEADER_SIZE
|
||||
payload = bytearray()
|
||||
for block_index, name in enumerate(names):
|
||||
data = files[name]
|
||||
payload.extend(data)
|
||||
block_entries.append([
|
||||
cursor,
|
||||
len(data),
|
||||
len(data),
|
||||
MPQ_FILE_EXISTS | MPQ_FILE_SINGLE_UNIT,
|
||||
])
|
||||
|
||||
slot = mpq_hash_string(name, 0) & (hash_table_size - 1)
|
||||
while hash_entries[slot][3] not in (MPQ_HASH_ENTRY_EMPTY, MPQ_HASH_ENTRY_DELETED):
|
||||
slot = (slot + 1) & (hash_table_size - 1)
|
||||
hash_entries[slot] = [
|
||||
mpq_hash_string(name, 1),
|
||||
mpq_hash_string(name, 2),
|
||||
0,
|
||||
block_index,
|
||||
]
|
||||
cursor += len(data)
|
||||
|
||||
hash_table_pos = cursor
|
||||
hash_words = [word for entry in hash_entries for word in entry]
|
||||
hash_table = mpq_encrypt_words(hash_words, mpq_hash_string("(hash table)", 3))
|
||||
cursor += len(hash_table)
|
||||
|
||||
block_table_pos = cursor
|
||||
block_words = [word for entry in block_entries for word in entry]
|
||||
block_table = mpq_encrypt_words(block_words, mpq_hash_string("(block table)", 3))
|
||||
cursor += len(block_table)
|
||||
|
||||
header = struct.pack(
|
||||
MPQ_HEADER_FMT,
|
||||
MPQ_MAGIC,
|
||||
MPQ_HEADER_SIZE,
|
||||
cursor,
|
||||
0,
|
||||
3,
|
||||
hash_table_pos,
|
||||
block_table_pos,
|
||||
hash_table_size,
|
||||
len(block_entries),
|
||||
)
|
||||
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output_file.open("wb") as f:
|
||||
f.write(header)
|
||||
f.write(payload)
|
||||
f.write(hash_table)
|
||||
f.write(block_table)
|
||||
|
||||
|
||||
def verify_mpq(files: dict, mpq_file: Path) -> None:
|
||||
data = mpq_file.read_bytes()
|
||||
if len(data) < MPQ_HEADER_SIZE:
|
||||
raise ValueError(f"{mpq_file} too short to be an MPQ")
|
||||
(
|
||||
magic,
|
||||
header_size,
|
||||
archive_size,
|
||||
fmt_version,
|
||||
_block_size,
|
||||
hash_table_pos,
|
||||
block_table_pos,
|
||||
hash_table_size,
|
||||
block_table_size,
|
||||
) = struct.unpack(MPQ_HEADER_FMT, data[:MPQ_HEADER_SIZE])
|
||||
if magic != MPQ_MAGIC or header_size != MPQ_HEADER_SIZE or fmt_version != 0:
|
||||
raise ValueError(f"{mpq_file} has an unsupported MPQ header")
|
||||
if archive_size != len(data):
|
||||
raise ValueError(f"{mpq_file} size mismatch: header={archive_size}, actual={len(data)}")
|
||||
|
||||
hash_bytes = data[hash_table_pos : hash_table_pos + hash_table_size * 16]
|
||||
block_bytes = data[block_table_pos : block_table_pos + block_table_size * 16]
|
||||
hash_words = mpq_decrypt_words(hash_bytes, mpq_hash_string("(hash table)", 3))
|
||||
block_words = mpq_decrypt_words(block_bytes, mpq_hash_string("(block table)", 3))
|
||||
|
||||
hash_entries = [hash_words[i:i + 4] for i in range(0, len(hash_words), 4)]
|
||||
block_entries = [block_words[i:i + 4] for i in range(0, len(block_words), 4)]
|
||||
for name, expected in files.items():
|
||||
slot = mpq_hash_string(name, 0) & (hash_table_size - 1)
|
||||
name1 = mpq_hash_string(name, 1)
|
||||
name2 = mpq_hash_string(name, 2)
|
||||
for _ in range(hash_table_size):
|
||||
entry = hash_entries[slot]
|
||||
if entry[3] == MPQ_HASH_ENTRY_EMPTY:
|
||||
break
|
||||
if entry[0] == name1 and entry[1] == name2:
|
||||
block_index = entry[3]
|
||||
file_pos, compressed_size, file_size, flags = block_entries[block_index]
|
||||
actual = data[file_pos:file_pos + compressed_size]
|
||||
if flags != (MPQ_FILE_EXISTS | MPQ_FILE_SINGLE_UNIT):
|
||||
raise ValueError(f"{mpq_file}: unexpected flags for {name}: 0x{flags:08X}")
|
||||
if file_size != len(expected) or actual != expected:
|
||||
raise ValueError(f"{mpq_file}: payload mismatch for {name}")
|
||||
break
|
||||
slot = (slot + 1) & (hash_table_size - 1)
|
||||
else:
|
||||
raise ValueError(f"{mpq_file}: hash table probe exhausted for {name}")
|
||||
if hash_entries[slot][0] != name1 or hash_entries[slot][1] != name2:
|
||||
raise ValueError(f"{mpq_file}: missing {name}")
|
||||
|
||||
|
||||
def sort_records_by_id(dbc: "DBC") -> None:
|
||||
"""Keep DBC records in ascending ID order.
|
||||
|
||||
AzerothCore's server loader builds an explicit ID index and tolerates
|
||||
records appended out of order. The 3.3.5a client is stricter around taxi
|
||||
UI lookups, so custom IDs that fill holes below the vanilla max must be
|
||||
inserted into sorted order instead of left at EOF.
|
||||
"""
|
||||
dbc.records.sort(key=lambda r: struct.unpack_from("<I", r, 0)[0])
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# .env loader (avoids adding python-dotenv as a dep) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def load_env_file(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DBC binary I/O #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@dataclass
|
||||
class DBC:
|
||||
"""Mutable in-memory DBC: a list of record bytes + the string block."""
|
||||
|
||||
field_count: int
|
||||
record_size: int
|
||||
records: list # list[bytes], each exactly record_size bytes
|
||||
string_block: bytearray
|
||||
|
||||
@classmethod
|
||||
def read(cls, path: Path) -> "DBC":
|
||||
data = path.read_bytes()
|
||||
if len(data) < DBC_HEADER_SIZE:
|
||||
raise ValueError(f"{path} too short to be a DBC")
|
||||
magic, n_rec, n_field, rec_size, sb_size = struct.unpack(
|
||||
DBC_HEADER_FMT, data[:DBC_HEADER_SIZE]
|
||||
)
|
||||
if magic != DBC_MAGIC:
|
||||
raise ValueError(f"{path} bad magic {magic!r}, expected {DBC_MAGIC!r}")
|
||||
records_blob_size = n_rec * rec_size
|
||||
records_end = DBC_HEADER_SIZE + records_blob_size
|
||||
sb_end = records_end + sb_size
|
||||
if sb_end != len(data):
|
||||
raise ValueError(
|
||||
f"{path} size mismatch: header says {sb_end} bytes, file is {len(data)}"
|
||||
)
|
||||
records = [
|
||||
data[DBC_HEADER_SIZE + i * rec_size : DBC_HEADER_SIZE + (i + 1) * rec_size]
|
||||
for i in range(n_rec)
|
||||
]
|
||||
return cls(
|
||||
field_count=n_field,
|
||||
record_size=rec_size,
|
||||
records=records,
|
||||
string_block=bytearray(data[records_end:sb_end]),
|
||||
)
|
||||
|
||||
def write(self, path: Path) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("wb") as f:
|
||||
f.write(struct.pack(
|
||||
DBC_HEADER_FMT,
|
||||
DBC_MAGIC,
|
||||
len(self.records),
|
||||
self.field_count,
|
||||
self.record_size,
|
||||
len(self.string_block),
|
||||
))
|
||||
for r in self.records:
|
||||
if len(r) != self.record_size:
|
||||
raise ValueError(
|
||||
f"record size {len(r)} != schema {self.record_size}"
|
||||
)
|
||||
f.write(r)
|
||||
f.write(self.string_block)
|
||||
|
||||
def append_string(self, s: str) -> int:
|
||||
"""Append a UTF-8 string to the block and return its offset."""
|
||||
# An empty string block always starts with a NUL byte at offset 0
|
||||
# (so any record's "no string" can point to offset 0). Real DBCs
|
||||
# already have this from vanilla, but we guard for safety.
|
||||
if not self.string_block:
|
||||
self.string_block.append(0)
|
||||
offset = len(self.string_block)
|
||||
self.string_block.extend(s.encode("utf-8"))
|
||||
self.string_block.append(0)
|
||||
return offset
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Schema-specific record helpers #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def pack_taxinode_record(
|
||||
*,
|
||||
node_id: int,
|
||||
map_id: int,
|
||||
x: float,
|
||||
y: float,
|
||||
z: float,
|
||||
name_offset: int,
|
||||
mount_alliance: int,
|
||||
mount_horde: int,
|
||||
locale_slot: int,
|
||||
name_lang_mask: int,
|
||||
) -> bytes:
|
||||
"""Build a 96-byte TaxiNodes record.
|
||||
|
||||
The vanilla 3.3.5a client expects locale-aware DBCs: the string lives
|
||||
only in the slot that matches the file's locale (e.g. slot 8 for ruRU
|
||||
DBCs), the other 15 slots stay 0, and `name_lang_mask` carries a
|
||||
Blizzard-internal bitmask. Writing the string into every slot or
|
||||
using a different mask trips strict clients (DragonUI / modded
|
||||
Wow.exe builds crash on `LookupEntry`-style lookups with a NULL
|
||||
deref at TaxiNodesEntry+12).
|
||||
|
||||
Caller computes the right `locale_slot` and `name_lang_mask` by
|
||||
sampling a vanilla record (see infer_taxinodes_locale_layout).
|
||||
|
||||
Note: in the DBC binary, MountCreatureID[0] is the *Horde* mount and
|
||||
MountCreatureID[1] is the *Alliance* mount (see ObjectMgr.cpp:7229
|
||||
`teamId == TEAM_ALLIANCE ? 1 : 0`). The taxi-builder spec text is
|
||||
inverted; trust the runtime usage."""
|
||||
if not (0 <= locale_slot < 16):
|
||||
raise ValueError(f"locale_slot {locale_slot} out of range 0..15")
|
||||
parts = [
|
||||
struct.pack("<II", node_id, map_id),
|
||||
struct.pack("<fff", x, y, z),
|
||||
]
|
||||
for i in range(16):
|
||||
parts.append(struct.pack("<I", name_offset if i == locale_slot else 0))
|
||||
parts.append(struct.pack("<III", name_lang_mask, mount_horde, mount_alliance))
|
||||
rec = b"".join(parts)
|
||||
assert len(rec) == TAXI_NODES_RECSIZE, len(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def infer_taxinodes_locale_layout(dbc: "DBC") -> tuple:
|
||||
"""Walk vanilla TaxiNodes records and figure out (a) which locale slot
|
||||
Blizzard's localized DBC uses (the one with non-zero offsets), and
|
||||
(b) the typical `name_lang_mask` value. Returns (locale_slot, mask).
|
||||
Falls back to (8, 0xFF00FE) — ruRU + observed vanilla mask — if the
|
||||
file is empty."""
|
||||
mask_counts = {}
|
||||
slot_counts = [0] * 16
|
||||
for r in dbc.records:
|
||||
for i in range(16):
|
||||
off = struct.unpack_from("<I", r, 8 + 12 + i * 4)[0]
|
||||
if off:
|
||||
slot_counts[i] += 1
|
||||
m = struct.unpack_from("<I", r, 8 + 12 + 16 * 4)[0]
|
||||
mask_counts[m] = mask_counts.get(m, 0) + 1
|
||||
best_slot = max(range(16), key=lambda i: slot_counts[i]) if any(slot_counts) else 8
|
||||
best_mask = max(mask_counts, key=mask_counts.get) if mask_counts else 0xFF00FE
|
||||
return best_slot, best_mask
|
||||
|
||||
|
||||
def pack_taxipath_record(*, path_id: int, from_node: int, to_node: int, cost: int) -> bytes:
|
||||
rec = struct.pack("<IIII", path_id, from_node, to_node, cost)
|
||||
assert len(rec) == TAXI_PATH_RECSIZE, len(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def pack_taxipathnode_record(
|
||||
*,
|
||||
record_id: int,
|
||||
path_id: int,
|
||||
node_index: int,
|
||||
map_id: int,
|
||||
x: float,
|
||||
y: float,
|
||||
z: float,
|
||||
flags: int,
|
||||
delay: int,
|
||||
arrival_event: int,
|
||||
departure_event: int,
|
||||
) -> bytes:
|
||||
rec = struct.pack(
|
||||
"<IIIIfffIIII",
|
||||
record_id, path_id, node_index, map_id,
|
||||
x, y, z,
|
||||
flags, delay, arrival_event, departure_event,
|
||||
)
|
||||
assert len(rec) == TAXI_PATH_NODE_RECSIZE, len(rec)
|
||||
return rec
|
||||
|
||||
|
||||
def vanilla_max_id_uint32_at_offset(records: list, byte_offset: int) -> int:
|
||||
"""Scan `records` and return the max uint32 read at byte_offset."""
|
||||
best = 0
|
||||
for r in records:
|
||||
v = struct.unpack_from("<I", r, byte_offset)[0]
|
||||
if v > best:
|
||||
best = v
|
||||
return best
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# DB access #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def db_connect(args):
|
||||
import pymysql
|
||||
return pymysql.connect(
|
||||
host=args.db_host,
|
||||
port=args.db_port,
|
||||
user=args.db_user,
|
||||
password=args.db_password,
|
||||
database=args.db_name,
|
||||
charset="utf8mb4",
|
||||
cursorclass=pymysql.cursors.DictCursor,
|
||||
)
|
||||
|
||||
|
||||
def fetch_custom_taxi_nodes(conn):
|
||||
with conn.cursor() as c:
|
||||
c.execute(
|
||||
"SELECT id, name, map_id, position_x, position_y, position_z, "
|
||||
" mount_alliance, mount_horde "
|
||||
"FROM custom_taxi_nodes ORDER BY id"
|
||||
)
|
||||
return list(c.fetchall())
|
||||
|
||||
|
||||
def fetch_custom_taxi_paths(conn):
|
||||
with conn.cursor() as c:
|
||||
c.execute(
|
||||
"SELECT id, from_taxi_node_id, to_taxi_node_id, cost "
|
||||
"FROM custom_taxi_paths WHERE is_enabled = 1 ORDER BY id"
|
||||
)
|
||||
return list(c.fetchall())
|
||||
|
||||
|
||||
def fetch_custom_taxi_path_nodes(conn):
|
||||
with conn.cursor() as c:
|
||||
c.execute(
|
||||
"SELECT path_id, node_index, map_id, position_x, position_y, position_z, "
|
||||
" flags, delay, arrival_event_id, departure_event_id "
|
||||
"FROM custom_taxi_path_nodes ORDER BY path_id, node_index"
|
||||
)
|
||||
return list(c.fetchall())
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Export pipeline #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def merge_taxinodes(dbc: DBC, custom_rows: list) -> int:
|
||||
"""Append custom rows to the loaded TaxiNodes DBC. Returns the count
|
||||
of appended records. Picks the locale_slot and name_lang_mask from
|
||||
the vanilla file so our records are byte-shaped exactly like the
|
||||
surrounding Blizzard data — strict clients reject anything else."""
|
||||
if dbc.record_size != TAXI_NODES_RECSIZE:
|
||||
raise ValueError(
|
||||
f"TaxiNodes.dbc record size {dbc.record_size} != expected {TAXI_NODES_RECSIZE}"
|
||||
)
|
||||
|
||||
locale_slot, lang_mask = infer_taxinodes_locale_layout(dbc)
|
||||
print(f"TaxiNodes locale layout: slot={locale_slot}, lang_mask=0x{lang_mask:08X}")
|
||||
|
||||
vanilla_ids = {struct.unpack_from("<I", r, 0)[0] for r in dbc.records}
|
||||
appended = 0
|
||||
for row in custom_rows:
|
||||
if row["id"] in vanilla_ids:
|
||||
raise ValueError(
|
||||
f"custom TaxiNode id {row['id']} collides with a vanilla TaxiNode id"
|
||||
)
|
||||
offset = dbc.append_string(row["name"])
|
||||
dbc.records.append(pack_taxinode_record(
|
||||
node_id=row["id"],
|
||||
map_id=row["map_id"],
|
||||
x=row["position_x"],
|
||||
y=row["position_y"],
|
||||
z=row["position_z"],
|
||||
name_offset=offset,
|
||||
mount_alliance=row["mount_alliance"],
|
||||
mount_horde=row["mount_horde"],
|
||||
locale_slot=locale_slot,
|
||||
name_lang_mask=lang_mask,
|
||||
))
|
||||
appended += 1
|
||||
sort_records_by_id(dbc)
|
||||
return appended
|
||||
|
||||
|
||||
def merge_taxipath(dbc: DBC, custom_rows: list) -> int:
|
||||
if dbc.record_size != TAXI_PATH_RECSIZE:
|
||||
raise ValueError(
|
||||
f"TaxiPath.dbc record size {dbc.record_size} != expected {TAXI_PATH_RECSIZE}"
|
||||
)
|
||||
|
||||
vanilla_ids = {struct.unpack_from("<I", r, 0)[0] for r in dbc.records}
|
||||
appended = 0
|
||||
for row in custom_rows:
|
||||
if row["id"] in vanilla_ids:
|
||||
raise ValueError(
|
||||
f"custom TaxiPath id {row['id']} collides with a vanilla TaxiPath id"
|
||||
)
|
||||
dbc.records.append(pack_taxipath_record(
|
||||
path_id=row["id"],
|
||||
from_node=row["from_taxi_node_id"],
|
||||
to_node=row["to_taxi_node_id"],
|
||||
cost=row["cost"],
|
||||
))
|
||||
appended += 1
|
||||
sort_records_by_id(dbc)
|
||||
return appended
|
||||
|
||||
|
||||
def merge_taxipathnode(dbc: DBC, custom_rows: list) -> int:
|
||||
if dbc.record_size != TAXI_PATH_NODE_RECSIZE:
|
||||
raise ValueError(
|
||||
f"TaxiPathNode.dbc record size {dbc.record_size} != expected {TAXI_PATH_NODE_RECSIZE}"
|
||||
)
|
||||
|
||||
next_id = vanilla_max_id_uint32_at_offset(dbc.records, 0) + 1
|
||||
appended = 0
|
||||
for row in custom_rows:
|
||||
dbc.records.append(pack_taxipathnode_record(
|
||||
record_id=next_id,
|
||||
path_id=row["path_id"],
|
||||
node_index=row["node_index"],
|
||||
map_id=row["map_id"],
|
||||
x=row["position_x"],
|
||||
y=row["position_y"],
|
||||
z=row["position_z"],
|
||||
flags=row["flags"],
|
||||
delay=row["delay"],
|
||||
arrival_event=row["arrival_event_id"],
|
||||
departure_event=row["departure_event_id"],
|
||||
))
|
||||
next_id += 1
|
||||
appended += 1
|
||||
sort_records_by_id(dbc)
|
||||
return appended
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# sha256 helpers #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as f:
|
||||
for chunk in iter(lambda: f.read(65536), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def write_manifest(folder: Path, hashes: dict) -> None:
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
lines = [f"{h} {name}" for name, h in sorted(hashes.items())]
|
||||
(folder / "sha256.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Commands #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def cmd_export_dbc(args) -> int:
|
||||
vanilla_dir = Path(args.vanilla_dir).expanduser().resolve()
|
||||
output_dir = Path(args.output_dir).expanduser().resolve()
|
||||
|
||||
server_dir = output_dir / "server" / "dbc"
|
||||
client_dir = output_dir / "client" / "DBFilesClient"
|
||||
|
||||
print(f"Vanilla source: {vanilla_dir}")
|
||||
print(f"Output: {output_dir}")
|
||||
|
||||
# Validate vanilla files exist before touching the DB.
|
||||
for fname in DBC_FILES:
|
||||
p = vanilla_dir / fname
|
||||
if not p.is_file():
|
||||
print(f"ERROR: vanilla DBC missing: {p}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print("Connecting to MySQL...")
|
||||
conn = db_connect(args)
|
||||
try:
|
||||
nodes = fetch_custom_taxi_nodes(conn)
|
||||
paths = fetch_custom_taxi_paths(conn)
|
||||
wps = fetch_custom_taxi_path_nodes(conn)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
print(f"Custom nodes: {len(nodes)}, paths: {len(paths)}, waypoints: {len(wps)}")
|
||||
|
||||
# Sanity checks on the data before we burn DBC bytes.
|
||||
if paths and not wps:
|
||||
print("WARNING: paths defined but no waypoints — generated DBC will be useless.",
|
||||
file=sys.stderr)
|
||||
enabled_path_ids = {p["id"] for p in paths}
|
||||
waypoints_by_path = {}
|
||||
for wp in wps:
|
||||
waypoints_by_path.setdefault(wp["path_id"], []).append(wp)
|
||||
for pid in waypoints_by_path:
|
||||
if pid not in enabled_path_ids:
|
||||
print(f"WARNING: waypoints reference disabled/unknown path_id={pid}; skipping.",
|
||||
file=sys.stderr)
|
||||
wps_filtered = [wp for wp in wps if wp["path_id"] in enabled_path_ids]
|
||||
|
||||
# Read vanilla, merge, write.
|
||||
nodes_dbc = DBC.read(vanilla_dir / "TaxiNodes.dbc")
|
||||
paths_dbc = DBC.read(vanilla_dir / "TaxiPath.dbc")
|
||||
pn_dbc = DBC.read(vanilla_dir / "TaxiPathNode.dbc")
|
||||
|
||||
# Compounding guard: if the source already contains records in the
|
||||
# taxi-builder custom-id space, the user is pointing --vanilla-dir at
|
||||
# an already-merged tree. Continuing would duplicate entries.
|
||||
# NB: TaxiMaskSize=14 caps node ids at 448 — vanilla 3.3.5a uses up to
|
||||
# ~440, so 441 is the practical floor for custom node ids. Path ids are
|
||||
# not bound by the mask and live in their own large range.
|
||||
CUSTOM_NODE_FLOOR = 441
|
||||
CUSTOM_PATH_FLOOR = 1979
|
||||
seen_nodes = sum(
|
||||
1 for r in nodes_dbc.records
|
||||
if struct.unpack_from("<I", r, 0)[0] >= CUSTOM_NODE_FLOOR
|
||||
)
|
||||
seen_paths = sum(
|
||||
1 for r in paths_dbc.records
|
||||
if struct.unpack_from("<I", r, 0)[0] >= CUSTOM_PATH_FLOOR
|
||||
)
|
||||
if seen_nodes or seen_paths:
|
||||
print(
|
||||
f"ERROR: vanilla source at {vanilla_dir} already contains "
|
||||
f"{seen_nodes} TaxiNodes id>={CUSTOM_NODE_FLOOR} and "
|
||||
f"{seen_paths} TaxiPath id>={CUSTOM_PATH_FLOOR} — looks already-merged.\n"
|
||||
f" Point --vanilla-dir at a clean snapshot, or restore "
|
||||
f"vanilla DBCs into {vanilla_dir} before re-running.",
|
||||
file=sys.stderr)
|
||||
return 3
|
||||
|
||||
n_appended_nodes = merge_taxinodes(nodes_dbc, nodes)
|
||||
n_appended_paths = merge_taxipath(paths_dbc, paths)
|
||||
n_appended_pn = merge_taxipathnode(pn_dbc, wps_filtered)
|
||||
|
||||
print(f"Appended: TaxiNodes +{n_appended_nodes}, "
|
||||
f"TaxiPath +{n_appended_paths}, "
|
||||
f"TaxiPathNode +{n_appended_pn}")
|
||||
print(f"Final: TaxiNodes {len(nodes_dbc.records)} records, "
|
||||
f"TaxiPath {len(paths_dbc.records)} records, "
|
||||
f"TaxiPathNode {len(pn_dbc.records)} records")
|
||||
|
||||
# Write to server/dbc and client/DBFilesClient. Same bytes both sides.
|
||||
targets = {
|
||||
"TaxiNodes.dbc": nodes_dbc,
|
||||
"TaxiPath.dbc": paths_dbc,
|
||||
"TaxiPathNode.dbc": pn_dbc,
|
||||
}
|
||||
server_hashes = {}
|
||||
client_hashes = {}
|
||||
for fname, dbc in targets.items():
|
||||
spath = server_dir / fname
|
||||
cpath = client_dir / fname
|
||||
dbc.write(spath)
|
||||
dbc.write(cpath)
|
||||
server_hashes[fname] = sha256_file(spath)
|
||||
client_hashes[fname] = sha256_file(cpath)
|
||||
|
||||
write_manifest(server_dir, server_hashes)
|
||||
write_manifest(client_dir, client_hashes)
|
||||
|
||||
print()
|
||||
print("sha256 (server):")
|
||||
for fname, h in sorted(server_hashes.items()):
|
||||
print(f" {h} {fname}")
|
||||
print("sha256 (client):")
|
||||
for fname, h in sorted(client_hashes.items()):
|
||||
print(f" {h} {fname}")
|
||||
|
||||
if server_hashes != client_hashes:
|
||||
print("Server/client DBC match: FAIL", file=sys.stderr)
|
||||
return 1
|
||||
print("Server/client DBC match: OK")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_build_client_patch(args) -> int:
|
||||
input_dir = Path(args.input_dir).expanduser().resolve()
|
||||
output_file = Path(args.output_file).expanduser().resolve()
|
||||
|
||||
print(f"Client DBC input: {input_dir}")
|
||||
print(f"MPQ output: {output_file}")
|
||||
|
||||
files = {}
|
||||
listfile_lines = []
|
||||
for internal_path, fname in zip(CLIENT_MPQ_PATHS, DBC_FILES):
|
||||
source = input_dir / fname
|
||||
if not source.is_file():
|
||||
print(f"ERROR: client DBC missing: {source}", file=sys.stderr)
|
||||
return 2
|
||||
files[internal_path] = source.read_bytes()
|
||||
listfile_lines.append(internal_path)
|
||||
|
||||
files["(listfile)"] = ("\r\n".join(listfile_lines) + "\r\n").encode("ascii")
|
||||
|
||||
build_mpq(files, output_file)
|
||||
verify_mpq(files, output_file)
|
||||
|
||||
print()
|
||||
print(f"MPQ sha256: {sha256_file(output_file)} {output_file.name}")
|
||||
print("Client install:")
|
||||
print(f" Copy to: <WoW 3.3.5a>\\Data\\{output_file.name}")
|
||||
print(" Then delete the client Cache directory and restart Wow.exe.")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_dump_vanilla(args) -> int:
|
||||
"""Read a vanilla DBC and dump header + first few raw records."""
|
||||
p = Path(args.dbc).expanduser().resolve()
|
||||
dbc = DBC.read(p)
|
||||
print(f"{p}")
|
||||
print(f" field_count={dbc.field_count} record_size={dbc.record_size} "
|
||||
f"records={len(dbc.records)} string_block={len(dbc.string_block)}")
|
||||
n = min(args.limit, len(dbc.records))
|
||||
for i in range(n):
|
||||
r = dbc.records[i]
|
||||
ints = struct.unpack(f"<{dbc.record_size // 4}I", r)
|
||||
print(f" [{i}] {ints}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_thin(args) -> int:
|
||||
"""Drop intermediate waypoints that sit within args.min_yd (XY) of the
|
||||
previously kept point. Always keeps the first and the last waypoint;
|
||||
re-numbers node_index from 0 in place. Original rows are saved to a
|
||||
backup table so the operation is reversible."""
|
||||
import time
|
||||
|
||||
if args.min_yd < 1.0:
|
||||
print("--min-yd must be >= 1", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
conn = db_connect(args)
|
||||
try:
|
||||
with conn.cursor() as c:
|
||||
c.execute(
|
||||
"SELECT id, path_id, node_index, map_id, position_x, position_y, position_z, "
|
||||
" flags, delay, arrival_event_id, departure_event_id, comment "
|
||||
"FROM custom_taxi_path_nodes WHERE path_id = %s ORDER BY node_index",
|
||||
(args.path,),
|
||||
)
|
||||
rows = list(c.fetchall())
|
||||
|
||||
if not rows:
|
||||
print(f"path {args.path} has no waypoints", file=sys.stderr)
|
||||
return 1
|
||||
if len(rows) < 3:
|
||||
print(f"path {args.path} has only {len(rows)} waypoints — nothing to thin")
|
||||
return 0
|
||||
|
||||
def dist_xy(a, b):
|
||||
dx = a["position_x"] - b["position_x"]
|
||||
dy = a["position_y"] - b["position_y"]
|
||||
return (dx * dx + dy * dy) ** 0.5
|
||||
|
||||
kept = [rows[0]]
|
||||
for wp in rows[1:-1]:
|
||||
if wp["map_id"] != kept[-1]["map_id"]:
|
||||
kept.append(wp)
|
||||
continue
|
||||
if dist_xy(wp, kept[-1]) >= args.min_yd:
|
||||
kept.append(wp)
|
||||
kept.append(rows[-1])
|
||||
|
||||
# If the second-to-last we kept is now inside the spacing window
|
||||
# of the final point, drop it — we want the last point unique.
|
||||
while (
|
||||
len(kept) >= 3
|
||||
and kept[-2]["map_id"] == kept[-1]["map_id"]
|
||||
and dist_xy(kept[-2], kept[-1]) < args.min_yd
|
||||
):
|
||||
kept.pop(-2)
|
||||
|
||||
print(f"Path {args.path}: {len(rows)} -> {len(kept)} waypoints "
|
||||
f"(min_yd={args.min_yd}, removed {len(rows) - len(kept)})")
|
||||
|
||||
if args.dry_run:
|
||||
print("(dry run — DB not modified). New sequence:")
|
||||
for i, wp in enumerate(kept):
|
||||
seg = ""
|
||||
if i > 0 and kept[i - 1]["map_id"] == wp["map_id"]:
|
||||
seg = f" seg={dist_xy(kept[i - 1], wp):.1f}yd"
|
||||
print(f" [{i:>3}] (was {wp['node_index']:>3}) map={wp['map_id']} "
|
||||
f"({wp['position_x']:.1f}, {wp['position_y']:.1f}, {wp['position_z']:.1f}){seg}")
|
||||
return 0
|
||||
|
||||
ts = int(time.time())
|
||||
backup_table = f"custom_taxi_path_nodes_backup_{args.path}_{ts}"
|
||||
with conn.cursor() as c:
|
||||
c.execute(
|
||||
f"CREATE TABLE `{backup_table}` AS "
|
||||
"SELECT * FROM custom_taxi_path_nodes WHERE path_id = %s",
|
||||
(args.path,),
|
||||
)
|
||||
c.execute(
|
||||
"DELETE FROM custom_taxi_path_nodes WHERE path_id = %s",
|
||||
(args.path,),
|
||||
)
|
||||
for new_idx, wp in enumerate(kept):
|
||||
c.execute(
|
||||
"INSERT INTO custom_taxi_path_nodes "
|
||||
"(path_id, node_index, map_id, position_x, position_y, position_z, "
|
||||
" flags, delay, arrival_event_id, departure_event_id, comment) "
|
||||
"VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
|
||||
(args.path, new_idx, wp["map_id"],
|
||||
wp["position_x"], wp["position_y"], wp["position_z"],
|
||||
wp["flags"], wp["delay"],
|
||||
wp["arrival_event_id"], wp["departure_event_id"], wp["comment"]),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
print(f"Backup table: `{backup_table}`")
|
||||
print("To rollback:")
|
||||
print(f" DELETE FROM custom_taxi_path_nodes WHERE path_id = {args.path};")
|
||||
print(f" INSERT INTO custom_taxi_path_nodes "
|
||||
f"SELECT * FROM `{backup_table}`;")
|
||||
finally:
|
||||
conn.close()
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_not_implemented(args) -> int:
|
||||
print(f"`{args.subcommand}` is not implemented in the MVP.", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# CLI wiring #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent
|
||||
default_vanilla = repo_root / "var" / "client-data-vanilla"
|
||||
default_output = repo_root / "build" / "taxi" / "export"
|
||||
default_patch = default_output / "client" / "patch-Z.MPQ"
|
||||
|
||||
p = argparse.ArgumentParser(prog="moonwell-taxi", description=__doc__)
|
||||
sub = p.add_subparsers(dest="subcommand", required=True)
|
||||
|
||||
# Shared DB connection flags.
|
||||
def add_db_flags(sp):
|
||||
sp.add_argument("--db-host", default=os.environ.get("MOONWELL_TAXI_DB_HOST", "127.0.0.1"))
|
||||
sp.add_argument("--db-port", type=int,
|
||||
default=int(os.environ.get("DOCKER_DB_EXTERNAL_PORT", "3306")))
|
||||
sp.add_argument("--db-user", default=os.environ.get("MOONWELL_TAXI_DB_USER", "root"))
|
||||
sp.add_argument("--db-password", default=os.environ.get("DOCKER_DB_ROOT_PASSWORD", "password"))
|
||||
sp.add_argument("--db-name", default=os.environ.get("MOONWELL_TAXI_DB_NAME", "acore_world"))
|
||||
|
||||
sp_export = sub.add_parser("export-dbc", help="merge custom + vanilla into new DBCs")
|
||||
sp_export.add_argument("--vanilla-dir", default=str(default_vanilla),
|
||||
help="directory containing TaxiNodes.dbc / TaxiPath.dbc / TaxiPathNode.dbc "
|
||||
"(default: repo root)")
|
||||
sp_export.add_argument("--output-dir", default=str(default_output),
|
||||
help="output base; server/ and client/ subtrees are created here")
|
||||
add_db_flags(sp_export)
|
||||
sp_export.set_defaults(func=cmd_export_dbc)
|
||||
|
||||
sp_patch = sub.add_parser("build-client-patch", help="pack exported client DBCs into a WoW MPQ patch")
|
||||
sp_patch.add_argument("--input-dir", default=str(default_output / "client" / "DBFilesClient"),
|
||||
help="directory containing exported client Taxi*.dbc files")
|
||||
sp_patch.add_argument("--output-file", default=str(default_patch),
|
||||
help="MPQ file to write")
|
||||
sp_patch.set_defaults(func=cmd_build_client_patch)
|
||||
|
||||
sp_dump = sub.add_parser("dump-vanilla", help="dump header + records for a DBC")
|
||||
sp_dump.add_argument("dbc", help="path to a .dbc file")
|
||||
sp_dump.add_argument("--limit", type=int, default=5)
|
||||
sp_dump.set_defaults(func=cmd_dump_vanilla)
|
||||
|
||||
sp_thin = sub.add_parser("thin", help="drop too-close waypoints in a path")
|
||||
sp_thin.add_argument("--path", type=int, required=True, help="custom_taxi_paths.id to thin")
|
||||
sp_thin.add_argument("--min-yd", type=float, default=50.0,
|
||||
help="minimum XY segment distance to keep (default 50)")
|
||||
sp_thin.add_argument("--dry-run", action="store_true",
|
||||
help="show what would change, do not touch the DB")
|
||||
add_db_flags(sp_thin)
|
||||
sp_thin.set_defaults(func=cmd_thin)
|
||||
|
||||
for stub in ("build-mpq", "deploy-server", "backup", "rollback", "diff"):
|
||||
sp = sub.add_parser(stub, help=f"(not implemented in MVP) {stub}")
|
||||
sp.set_defaults(func=cmd_not_implemented)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent
|
||||
load_env_file(repo_root / ".env")
|
||||
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user