#!/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(" 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(" 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(" bytes: rec = struct.pack(" bytes: rec = struct.pack( " int: """Scan `records` and return the max uint32 read at byte_offset.""" best = 0 for r in records: v = struct.unpack_from(" 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(" 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(" 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("= CUSTOM_NODE_FLOOR ) seen_paths = sum( 1 for r in paths_dbc.records if struct.unpack_from("= 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: \\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())