Files
moonwell-core/tools/moonwell-taxi/moonwell_taxi.py
T
2026-05-11 18:35:47 +04:00

959 lines
35 KiB
Python

#!/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())