|
|
|
@@ -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())
|