Compare commits
2 Commits
game-mod
...
taxi-nodes
| Author | SHA1 | Date | |
|---|---|---|---|
| 1401e8dfb7 | |||
| 13747727cf |
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(grep -rn \"CREATE TABLE.*\\\\`game_tele\\\\`\" /home/sindo/moonwell-core/data/sql/base/db_world/)",
|
||||||
|
"Bash(grep -rn \"CREATE TABLE.*\\\\`waypoint_data\\\\`\\\\|CREATE TABLE.*\\\\`creature_addon\\\\`\" /home/sindo/moonwell-core/data/sql/base/db_world/)",
|
||||||
|
"Bash(bash import-custom-sql.sh)",
|
||||||
|
"Bash(grep -rn \"WorldDatabase\\\\.Execute\\(\\\\\".*{}.*\\\\\"\\\\|WorldDatabase\\\\.DirectExecute\\(\\\\\".*{}\\\\|WorldDatabase\\\\.Execute\\(\\\\\".*\\\\\".*,\" /home/sindo/moonwell-core/src/server/)",
|
||||||
|
"Bash(grep -n \"enum LocaleConstant\\\\|LocaleConstant\\\\s*$\\\\|LocaleConstant{\" /home/sindo/moonwell-core/src/server/shared/SharedDefines.h /home/sindo/moonwell-core/src/common/Common.h)",
|
||||||
|
"Bash(chmod +x /home/sindo/moonwell-core/tools/moonwell-taxi/bin/moonwell-taxi)",
|
||||||
|
"Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py dump-vanilla /home/sindo/moonwell-core/TaxiNodes.dbc --limit 3)",
|
||||||
|
"Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py dump-vanilla /home/sindo/moonwell-core/TaxiPath.dbc --limit 3)",
|
||||||
|
"Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py dump-vanilla /home/sindo/moonwell-core/TaxiPathNode.dbc --limit 3)",
|
||||||
|
"Bash(python3 -c \"import pymysql; print\\(pymysql.__version__\\)\")",
|
||||||
|
"Bash(python3 -m pip show pymysql)",
|
||||||
|
"Bash(python3 -m venv /home/sindo/moonwell-core/tools/moonwell-taxi/.venv)",
|
||||||
|
"Bash(/home/sindo/moonwell-core/tools/moonwell-taxi/.venv/bin/python -m pip install --quiet -r /home/sindo/moonwell-core/tools/moonwell-taxi/requirements.txt)",
|
||||||
|
"Bash(/home/sindo/moonwell-core/tools/moonwell-taxi/.venv/bin/python -c \"import pymysql; print\\('ok', pymysql.__version__\\)\")",
|
||||||
|
"Bash(apt-cache search *)",
|
||||||
|
"Read(//usr/lib/python3/dist-packages/**)",
|
||||||
|
"Bash(sudo apt install -y python3-pymysql)",
|
||||||
|
"Bash(python3 -c \"import pymysql; print\\('OK', pymysql.__version__\\)\")",
|
||||||
|
"Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py export-dbc)",
|
||||||
|
"Bash(BUILDKIT_PROGRESS=plain docker compose build ac-db-import)",
|
||||||
|
"Bash(tee /tmp/dbimport-build.log)",
|
||||||
|
"Bash(docker volume *)",
|
||||||
|
"Bash(chmod +x /home/sindo/moonwell-core/tools/moonwell-taxi/redeploy.sh)",
|
||||||
|
"Bash(grep -rn 'PSendSysMessage\\(\"[^\"]*{[^\"]*\",' /home/sindo/moonwell-core/src/server/scripts/Commands/)",
|
||||||
|
"Bash(grep -n \"class taxi_builder_commandscript\\\\|^[[:space:]]*}\\\\?[[:space:]]*$\\\\|namespace\" /home/sindo/moonwell-core/modules/mod-taxi-builder/src/cs_taxi_builder.cpp)",
|
||||||
|
"Bash(python3 tools/moonwell-taxi/moonwell_taxi.py export-dbc)",
|
||||||
|
"Bash(python3 tools/moonwell-taxi/moonwell_taxi.py thin --path 90000 --min-yd 50 --dry-run)",
|
||||||
|
"Bash(python3 -)",
|
||||||
|
"Bash(cp var/client-data-vanilla/Taxi*.dbc var/client-data-live/dbc/)",
|
||||||
|
"Bash(bash tools/moonwell-taxi/redeploy.sh)",
|
||||||
|
"Bash(cp /home/sindo/moonwell-core/var/client-data-vanilla/Taxi*.dbc /home/sindo/moonwell-core/var/client-data-live/dbc/ *)",
|
||||||
|
"Bash(cp /home/sindo/moonwell-core/build/taxi/export/server/dbc/Taxi*.dbc /home/sindo/moonwell-core/var/client-data-live/dbc/ *)"
|
||||||
|
],
|
||||||
|
"additionalDirectories": [
|
||||||
|
"/home/sindo/moonwell-core/var/client-data-vanilla",
|
||||||
|
"/home/sindo/moonwell-core/build/taxi/export/server/dbc"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
--
|
||||||
|
-- Taxi Route Builder: storage for custom flight nodes, paths and waypoints.
|
||||||
|
-- DBC files (TaxiNodes / TaxiPath / TaxiPathNode) are GENERATED from these
|
||||||
|
-- tables by the moonwell-taxi CLI. These tables are the source of truth.
|
||||||
|
--
|
||||||
|
-- ID space (enforced by the in-game module, not by this schema):
|
||||||
|
-- * custom_taxi_nodes.id >= 1000 (vanilla WotLK uses up to ~313)
|
||||||
|
-- * custom_taxi_paths.id >= 90000 (vanilla uses up to ~700)
|
||||||
|
-- from_taxi_node_id / to_taxi_node_id may reference EITHER a row in
|
||||||
|
-- custom_taxi_nodes OR a vanilla TaxiNodes.dbc id, so no FK is declared.
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `custom_taxi_nodes` (
|
||||||
|
`id` int unsigned NOT NULL,
|
||||||
|
`name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
|
||||||
|
`map_id` smallint unsigned NOT NULL DEFAULT '0',
|
||||||
|
`position_x` float NOT NULL DEFAULT '0',
|
||||||
|
`position_y` float NOT NULL DEFAULT '0',
|
||||||
|
`position_z` float NOT NULL DEFAULT '0',
|
||||||
|
`mount_alliance` int unsigned NOT NULL DEFAULT '0',
|
||||||
|
`mount_horde` int unsigned NOT NULL DEFAULT '0',
|
||||||
|
`created_by` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'character GUID of GM who created the node',
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Taxi Builder: custom flight masters (source for TaxiNodes.dbc)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `custom_taxi_paths` (
|
||||||
|
`id` int unsigned NOT NULL,
|
||||||
|
`from_taxi_node_id` int unsigned NOT NULL,
|
||||||
|
`to_taxi_node_id` int unsigned NOT NULL,
|
||||||
|
`cost` int unsigned NOT NULL DEFAULT '0' COMMENT 'flight cost in copper',
|
||||||
|
`is_enabled` tinyint(1) unsigned NOT NULL DEFAULT '1',
|
||||||
|
`created_by` bigint unsigned NOT NULL DEFAULT '0',
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
`updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_from_to` (`from_taxi_node_id`, `to_taxi_node_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Taxi Builder: custom directed paths (source for TaxiPath.dbc)';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS `custom_taxi_path_nodes` (
|
||||||
|
`id` int unsigned NOT NULL AUTO_INCREMENT,
|
||||||
|
`path_id` int unsigned NOT NULL,
|
||||||
|
`node_index` int unsigned NOT NULL COMMENT '0-based, contiguous within a path',
|
||||||
|
`map_id` smallint unsigned NOT NULL DEFAULT '0',
|
||||||
|
`position_x` float NOT NULL DEFAULT '0',
|
||||||
|
`position_y` float NOT NULL DEFAULT '0',
|
||||||
|
`position_z` float NOT NULL DEFAULT '0',
|
||||||
|
`flags` int unsigned NOT NULL DEFAULT '0' COMMENT 'TaxiPathNode flags (teleport=1, stop=2, ...)',
|
||||||
|
`delay` int unsigned NOT NULL DEFAULT '0',
|
||||||
|
`arrival_event_id` int unsigned NOT NULL DEFAULT '0',
|
||||||
|
`departure_event_id` int unsigned NOT NULL DEFAULT '0',
|
||||||
|
`comment` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '',
|
||||||
|
`created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uniq_path_index` (`path_id`, `node_index`),
|
||||||
|
KEY `idx_path` (`path_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||||
|
COMMENT='Taxi Builder: waypoints of custom paths (source for TaxiPathNode.dbc)';
|
||||||
@@ -234,6 +234,28 @@ import_aoe_loot_sql() {
|
|||||||
import_sql_file "acore_world" "$aoe_loot_sql_file"
|
import_sql_file "acore_world" "$aoe_loot_sql_file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import_taxi_builder_sql() {
|
||||||
|
# Source-of-truth tables for the Taxi Route Builder. The migration also
|
||||||
|
# lives in data/sql/updates/pending_db_world/ for AzerothCore DBUpdater,
|
||||||
|
# but we apply it eagerly here so deploy flows that don't rely on the
|
||||||
|
# worldserver-side updater still bootstrap correctly.
|
||||||
|
local taxi_builder_sql_file="$ROOT_DIR/data/sql/updates/pending_db_world/rev_1778279831177904414.sql"
|
||||||
|
|
||||||
|
if [[ ! -f "$taxi_builder_sql_file" ]]; then
|
||||||
|
log "taxi-builder migration not found, skipped"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if table_exists "acore_world" "custom_taxi_nodes" \
|
||||||
|
&& table_exists "acore_world" "custom_taxi_paths" \
|
||||||
|
&& table_exists "acore_world" "custom_taxi_path_nodes"; then
|
||||||
|
log "taxi-builder tables already exist, skipping"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
import_sql_file "acore_world" "$taxi_builder_sql_file"
|
||||||
|
}
|
||||||
|
|
||||||
if (($# > 0)); then
|
if (($# > 0)); then
|
||||||
case "$1" in
|
case "$1" in
|
||||||
-h|--help)
|
-h|--help)
|
||||||
@@ -260,6 +282,7 @@ fi
|
|||||||
|
|
||||||
import_mythicplus_sql
|
import_mythicplus_sql
|
||||||
import_aoe_loot_sql
|
import_aoe_loot_sql
|
||||||
|
import_taxi_builder_sql
|
||||||
import_store_sql
|
import_store_sql
|
||||||
|
|
||||||
log "custom SQL bootstrap completed"
|
log "custom SQL bootstrap completed"
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
This module is licensed under the GNU Affero General Public License v3 or
|
||||||
|
later. See https://www.gnu.org/licenses/agpl-3.0.html for the full text.
|
||||||
@@ -0,0 +1,753 @@
|
|||||||
|
# mod-taxi-builder
|
||||||
|
|
||||||
|
GM-инструмент для создания собственных точек полёта и маршрутов
|
||||||
|
прямо в игре на сервере MoonWell (AzerothCore 3.3.5a). Не требует
|
||||||
|
ручного редактирования DBC: все данные складываются в три таблицы
|
||||||
|
`acore_world`, а внешний CLI [moonwell-taxi](../../tools/moonwell-taxi/)
|
||||||
|
собирает из них `TaxiNodes.dbc`, `TaxiPath.dbc` и `TaxiPathNode.dbc`
|
||||||
|
для серверной `dbc/`-папки и клиентского патча.
|
||||||
|
|
||||||
|
Все команды требуют права `SEC_ADMINISTRATOR` (стандартный GM-уровень
|
||||||
|
3+).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Содержание
|
||||||
|
|
||||||
|
- [Установка](#установка)
|
||||||
|
- [Полный сценарий: новый маршрут с нуля](#полный-сценарий-новый-маршрут-с-нуля)
|
||||||
|
- [Команды](#команды)
|
||||||
|
- [`.taxi node`](#taxi-node)
|
||||||
|
- [`.taxi path`](#taxi-path)
|
||||||
|
- [`.taxi record`](#taxi-record)
|
||||||
|
- [`.taxi markers`](#taxi-markers)
|
||||||
|
- [`.taxi export dbc`](#taxi-export-dbc)
|
||||||
|
- [Лимит TaxiMask и распределение id](#лимит-taximask-и-распределение-id)
|
||||||
|
- [Правила валидации](#правила-валидации)
|
||||||
|
- [Прореживание точек: `moonwell-taxi thin`](#прореживание-точек-moonwell-taxi-thin)
|
||||||
|
- [Структура БД](#структура-бд)
|
||||||
|
- [Связь с CLI moonwell-taxi](#связь-с-cli-moonwell-taxi)
|
||||||
|
- [Известные ограничения](#известные-ограничения)
|
||||||
|
- [FAQ / типичные ошибки](#faq--типичные-ошибки)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
1. Клонируйте репозиторий ядра и убедитесь, что папка
|
||||||
|
`modules/mod-taxi-builder/` присутствует.
|
||||||
|
2. Примените миграцию схемы. Самый простой способ — через
|
||||||
|
[import-custom-sql.sh](../../import-custom-sql.sh):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d ac-database
|
||||||
|
bash import-custom-sql.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
В `acore_world` появятся три таблицы: `custom_taxi_nodes`,
|
||||||
|
`custom_taxi_paths`, `custom_taxi_path_nodes`. Скрипт идемпотентен —
|
||||||
|
повторный запуск пропустит уже созданные таблицы.
|
||||||
|
|
||||||
|
3. Соберите ядро со включенными модулями (значение по умолчанию):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p build && cd build
|
||||||
|
cmake .. -DCMAKE_INSTALL_PREFIX=$HOME/azeroth-server \
|
||||||
|
-DCMAKE_BUILD_TYPE=RelWithDebInfo \
|
||||||
|
-DSCRIPTS=static -DMODULES=static
|
||||||
|
make -j$(nproc) && make install
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Перезапустите worldserver. На старте появится строка о регистрации
|
||||||
|
`taxi_builder_commandscript`.
|
||||||
|
|
||||||
|
5. (Один раз) поставьте `PyMySQL` для CLI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install -y python3-pymysql
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Полный сценарий: новый маршрут с нуля
|
||||||
|
|
||||||
|
Допустим, GM хочет добавить кастомный маршрут «Златоземье → Штормград»
|
||||||
|
(вернее, «новая точка → существующий узел Штормграда `id=2`»).
|
||||||
|
|
||||||
|
```
|
||||||
|
# 1. Встаём ровно туда, где должна быть новая точка полёта.
|
||||||
|
.taxi node create "Златоземье"
|
||||||
|
# -> [taxi-builder] node created: id=441 name="Златоземье" map=0 ...
|
||||||
|
|
||||||
|
# 2. Создаём маршрут от новой точки к Штормграду (vanilla node id=2),
|
||||||
|
# стоимость — 10 медяков.
|
||||||
|
.taxi path create 441 2 10
|
||||||
|
# -> [taxi-builder] path created: id=1979 441 (Златоземье) -> 2 (Stormwind) ...
|
||||||
|
|
||||||
|
# 3. Запускаем запись маршрута.
|
||||||
|
.taxi record start 1979
|
||||||
|
# -> [taxi-builder] recording started for path 1979 (existing points: 0)
|
||||||
|
|
||||||
|
# 4. Идём/летим по будущему маршруту. На каждой ключевой точке:
|
||||||
|
.taxi record point
|
||||||
|
# (или с комментарием — пригодится для последующего отчёта)
|
||||||
|
.taxi record point "взлёт над таверной"
|
||||||
|
.taxi record point
|
||||||
|
.taxi record point
|
||||||
|
# ...записываем 15-25 точек...
|
||||||
|
|
||||||
|
# Если оступились — откат последней:
|
||||||
|
.taxi record undo
|
||||||
|
|
||||||
|
# 5. Завершаем запись. Точки сохраняются.
|
||||||
|
.taxi record finish
|
||||||
|
# -> path 1979 now has 18 waypoints.
|
||||||
|
|
||||||
|
# 6. Проверяем маршрут на типовые проблемы.
|
||||||
|
.taxi path validate 1979
|
||||||
|
# -> Validation: 0 errors, 2 warnings — OK
|
||||||
|
|
||||||
|
# (Опционально) Расставить визуальные маркеры на всех точках, обойти их пешком:
|
||||||
|
.taxi markers spawn 1979
|
||||||
|
# ... осмотр ...
|
||||||
|
.taxi markers clear 1979
|
||||||
|
|
||||||
|
# 7. Из консоли (или из чата) запускаем экспорт DBC.
|
||||||
|
moonwell-taxi export-dbc
|
||||||
|
|
||||||
|
# 8. Кладём серверные DBC в папку сервера и перезапускаем worldserver.
|
||||||
|
# 9. Кладём клиентские DBC в патч-MPQ и перезапускаем клиент (или удаляем Cache).
|
||||||
|
# 10. На месте мастера полётов в Златоземье спавним NPC с npcflag=flight master
|
||||||
|
# и фракцией Альянса/Орды (для MVP — стандартные команды AzerothCore .npc add).
|
||||||
|
# 11. Подходим к нему, выбираем путь, летим.
|
||||||
|
```
|
||||||
|
|
||||||
|
После шага 9 точка появится у мастеров полётов и игроки смогут летать
|
||||||
|
по записанному маршруту.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Команды
|
||||||
|
|
||||||
|
### `.taxi node`
|
||||||
|
|
||||||
|
Управление точками полёта (записи в `custom_taxi_nodes`).
|
||||||
|
|
||||||
|
#### `.taxi node create "Name"`
|
||||||
|
|
||||||
|
Создаёт новую такситочку из текущей позиции GM. Работает только из
|
||||||
|
игры (нужны координаты).
|
||||||
|
|
||||||
|
- `id` подбирается автоматически из безопасного диапазона (`>= 441`,
|
||||||
|
избегая конфликтов с vanilla TaxiNodes ~313).
|
||||||
|
- `mount_alliance` и `mount_horde` определяются автоматически: модуль
|
||||||
|
смотрит, какие mount-creature чаще всего используются у vanilla точек
|
||||||
|
на текущей карте, и подставляет их. Если карта неизвестна — fallback
|
||||||
|
`541/541` (как в примере ТЗ).
|
||||||
|
- Имя сохраняется как одна строка и при экспорте в DBC дублируется во
|
||||||
|
все 16 локалей (отображается на любом клиенте).
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi node create "Златоземье"
|
||||||
|
[taxi-builder] node created: id=441 name="Златоземье" map=0
|
||||||
|
xyz=(-9445.77, 79.40, 57.38) mounts=A:1572/H:295
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.taxi node list [filter]`
|
||||||
|
|
||||||
|
Печатает список точек: сначала custom (`custom_taxi_nodes`), затем
|
||||||
|
vanilla (`sTaxiNodesStore`). Без аргумента дамп ограничен 50 строками
|
||||||
|
(в чате не должно быть спама). С `filter` — подстрочный поиск без
|
||||||
|
учёта регистра. Доступна и из консоли.
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi node list Storm
|
||||||
|
[vanilla] 2 Stormwind, Elwynn @ map 0 (-9446.5, 64.4, 56.0)
|
||||||
|
[vanilla] 38 Stonewatch Tower, Redridge @ map 0 (-9241.3, -2280.7, 65.0)
|
||||||
|
Shown 2 (cap 50). Custom: 1, Vanilla: 365.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.taxi node free`
|
||||||
|
|
||||||
|
Печатает все свободные TaxiNode id в диапазоне `1..448`, сгруппированные
|
||||||
|
в непрерывные подряд-идущие отрезки. Полезно перед `.taxi node create`
|
||||||
|
посмотреть, сколько слотов осталось, и до создания убедиться, что
|
||||||
|
auto-allocator не упрётся. Подробности — в разделе
|
||||||
|
[Лимит TaxiMask и распределение id](#лимит-taximask-и-распределение-id).
|
||||||
|
|
||||||
|
```text
|
||||||
|
.taxi node free
|
||||||
|
Free TaxiNode ids in 1..448: 83 total (7 above-vanilla 441..448, 76 in vanilla gaps).
|
||||||
|
338..339 (2)
|
||||||
|
...
|
||||||
|
441..448 (8)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.taxi node goto <nodeId>`
|
||||||
|
|
||||||
|
Телепортирует GM к такситочке (custom или vanilla — что найдётся
|
||||||
|
первым). Удобно для быстрой проверки, что точка стоит, где надо.
|
||||||
|
|
||||||
|
```text
|
||||||
|
.taxi node goto 441
|
||||||
|
[taxi-builder] teleported to custom node 441 (Златоземье)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `.taxi path`
|
||||||
|
|
||||||
|
Управление маршрутами (записи в `custom_taxi_paths`).
|
||||||
|
|
||||||
|
#### `.taxi path create <fromNodeId> <toNodeId> <cost>`
|
||||||
|
|
||||||
|
Создаёт направленный маршрут между двумя taxi-нодами. Любой конец
|
||||||
|
может быть как custom-нодом, так и vanilla — модуль ищет по обоим
|
||||||
|
источникам и валит, если ID нигде нет. Если уже есть включённый путь
|
||||||
|
с тем же `from→to` — отказ (нельзя случайно создать дубль).
|
||||||
|
|
||||||
|
- `id` подбирается автоматически из диапазона `>= 1979`.
|
||||||
|
- `cost` хранится в медяках.
|
||||||
|
- `is_enabled` ставится в `1`.
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi path create 441 2 10
|
||||||
|
[taxi-builder] path created: id=1979 441 (Златоземье) -> 2 (Stormwind) cost=10 copper
|
||||||
|
Now record waypoints with: .taxi record start 1979 -> .taxi record point ... -> .taxi record finish
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.taxi path reverse <pathId>`
|
||||||
|
|
||||||
|
Создаёт обратный путь из существующего:
|
||||||
|
|
||||||
|
1. Делает новый `TaxiPath` `to → from` с тем же `cost`.
|
||||||
|
2. Копирует все `TaxiPathNode` исходного пути в обратном порядке,
|
||||||
|
пересчитывая `node_index` от `0`.
|
||||||
|
3. Печатает предупреждение из ТЗ: ручную проверку посадки никто не
|
||||||
|
отменял.
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi path reverse 1979
|
||||||
|
[taxi-builder] reversed path 1979 (441->2) into new path 1980 (2->441) with 18 waypoints.
|
||||||
|
WARNING: reverse paths are auto-generated. Check landing waypoints by hand
|
||||||
|
(`.taxi path goto 1980 <lastIndex>`)...
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.taxi path list`
|
||||||
|
|
||||||
|
Все custom-маршруты с резолвом имён узлов и счётчиком точек.
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi path list
|
||||||
|
1979 441 (Златоземье) -> 2 (Stormwind, Elwynn) cost=10 pts=18 enabled
|
||||||
|
1980 2 (Stormwind, Elwynn) -> 441 (Златоземье) cost=10 pts=18 enabled
|
||||||
|
Total: 2 custom paths.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.taxi path report <pathId>`
|
||||||
|
|
||||||
|
Подробный отчёт по маршруту в формате секции 5.3 ТЗ.
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi path report 1979
|
||||||
|
PathID: 1979 (enabled)
|
||||||
|
From: 441 Златоземье [custom]
|
||||||
|
To: 2 Stormwind, Elwynn [vanilla]
|
||||||
|
Cost: 10 copper
|
||||||
|
Points: 18
|
||||||
|
Total distance: 1842.3 yd
|
||||||
|
Min segment: 41.7 yd Max segment: 95.4 yd
|
||||||
|
Last point distance to ToTaxiNode: 4.8 yd
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.taxi path validate <pathId>`
|
||||||
|
|
||||||
|
Применяет правила секции 4 ТЗ. См. подробности в разделе
|
||||||
|
[Правила валидации](#правила-валидации).
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi path validate 1979
|
||||||
|
[warn] segment 7->8 is 38.2 yd (< 40 yd, may be filtered by IsNodeIncludedInShortenedPath)
|
||||||
|
[warn] sharp turn at NodeIndex 12: 89 deg (> 75). Consider adding intermediate points.
|
||||||
|
Validation: 0 errors, 2 warnings — OK
|
||||||
|
```
|
||||||
|
|
||||||
|
При наличии errors статус — `FAIL`. Маршрут с errors не пропустит
|
||||||
|
`.taxi path test`.
|
||||||
|
|
||||||
|
#### `.taxi path goto <pathId> <nodeIndex>`
|
||||||
|
|
||||||
|
Телепорт к конкретной точке маршрута. Удобно для шага «обход маркеров».
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi path goto 1979 7
|
||||||
|
[taxi-builder] teleported to path 1979, NodeIndex 7 map=0 (-9095.00, 230.00, 180.00)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.taxi path test <pathId>`
|
||||||
|
|
||||||
|
«Сухой запуск»: запускает валидацию (отказ при errors), затем
|
||||||
|
телепортирует GM на первую точку маршрута, развернув лицом ко второй.
|
||||||
|
Реальный полёт по таксиспрайнам в этой версии возможен только после
|
||||||
|
прогона `moonwell-taxi export-dbc` и перезапуска worldserver+клиента.
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi path test 1979
|
||||||
|
[warn] segment 7->8 is 38.2 yd ...
|
||||||
|
[taxi-builder] dropped at first waypoint of path 1979 (18 total).
|
||||||
|
Step through with `.taxi path goto 1979 <index>`.
|
||||||
|
Real taxi flight needs `moonwell-taxi export-dbc` + worldserver/client restart.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `.taxi record`
|
||||||
|
|
||||||
|
Запись точек маршрута. Запись — *сессия в памяти*, привязанная к GM-у:
|
||||||
|
рестарт worldserver очистит сессию, но уже записанные waypoint-ы в БД
|
||||||
|
останутся, и сессию можно продолжить через `.taxi record start <pathId>`.
|
||||||
|
|
||||||
|
#### `.taxi record start <pathId>`
|
||||||
|
|
||||||
|
Начинает запись для существующего custom-пути. Запоминает baseline —
|
||||||
|
число точек, которые были у пути до старта (для корректного `cancel`).
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi record start 1979
|
||||||
|
[taxi-builder] recording started for path 1979 (existing points: 0).
|
||||||
|
Use `.taxi record point ["comment"]` and `.taxi record finish` when done.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.taxi record point [comment]`
|
||||||
|
|
||||||
|
Записывает следующую точку с координатами и map_id GM-а. `node_index`
|
||||||
|
автоматически = `MAX(node_index) + 1` (или 0 для первой).
|
||||||
|
|
||||||
|
Опциональный комментарий пишется в `comment` колонку (до 255 символов).
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi record point
|
||||||
|
[taxi-builder] path=1979 point #0 map=0 (-9445.77, 79.40, 57.38)
|
||||||
|
|
||||||
|
.taxi record point "разворот над озером"
|
||||||
|
[taxi-builder] path=1979 point #1 map=0 (-9395.00, 80.00, 70.00) // разворот над озером
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.taxi record undo`
|
||||||
|
|
||||||
|
Удаляет последнюю записанную в этой сессии точку. Не даёт уйти ниже
|
||||||
|
baseline (точки, существовавшие до `start`, защищены).
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi record undo
|
||||||
|
[taxi-builder] removed point #17 from path 1979.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.taxi record finish`
|
||||||
|
|
||||||
|
Закрывает сессию. Точки остаются в БД. Печатает счётчики.
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi record finish
|
||||||
|
[taxi-builder] recording finished. Path 1979 now has 18 waypoints (added 18 this session).
|
||||||
|
Run `.taxi path report 1979` or `.taxi path validate 1979` to review.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.taxi record cancel`
|
||||||
|
|
||||||
|
Откатывает текущую сессию: удаляет ровно те точки, которые добавил
|
||||||
|
этот `start ... point ...`. Существовавшие до сессии waypoint-ы не
|
||||||
|
трогаются — это полезно при правке уже готового маршрута.
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi record cancel
|
||||||
|
[taxi-builder] recording cancelled for path 1979; rolled back to 5 waypoints.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `.taxi markers`
|
||||||
|
|
||||||
|
Временные визуальные маркеры на waypoint-ах. По умолчанию используется
|
||||||
|
GO `178559` (Banner Stand of Argent Crusade) — он мелкий, виден всем
|
||||||
|
и есть в стоковой `gameobject_template`. При желании смените
|
||||||
|
`MARKER_GO_ENTRY` в [src/cs_taxi_builder.cpp](src/cs_taxi_builder.cpp).
|
||||||
|
|
||||||
|
#### `.taxi markers spawn <pathId>`
|
||||||
|
|
||||||
|
Спавнит маркеры на всех точках маршрута, лежащих на текущей карте
|
||||||
|
GM-а. Точки на других континентах пропускаются (грид всё равно не
|
||||||
|
загружен — было бы бесполезно).
|
||||||
|
|
||||||
|
Маркеры держатся 24 часа или до явного `clear`. Повторный `spawn`
|
||||||
|
сначала чистит предыдущие, чтобы не плодить дубли.
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi markers spawn 1979
|
||||||
|
[taxi-builder] spawned 18 markers for path 1979 on map 0 (0 skipped on other maps).
|
||||||
|
Auto-despawn in 86400 s. Use `.taxi markers clear 1979` to remove now.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `.taxi markers clear <pathId>`
|
||||||
|
|
||||||
|
Удаляет все маркеры данного пути.
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi markers clear 1979
|
||||||
|
[taxi-builder] cleared 18 markers for path 1979.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `.taxi export dbc`
|
||||||
|
|
||||||
|
Внутри игры команда не делает экспорт сама — она лишь напоминает
|
||||||
|
запустить внешний CLI. Это сделано специально: генерация DBC, sha256
|
||||||
|
и копирование файлов — задача внешнего инструмента, чтобы не нагружать
|
||||||
|
runtime worldserver-а.
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi export dbc
|
||||||
|
[taxi-builder] export dbc stub: run `moonwell-taxi export-dbc` from CLI
|
||||||
|
```
|
||||||
|
|
||||||
|
См. раздел [Связь с CLI moonwell-taxi](#связь-с-cli-moonwell-taxi).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Лимит TaxiMask и распределение id
|
||||||
|
|
||||||
|
`TaxiNode.id` ограничен сверху значением **448**. Это hardcoded в
|
||||||
|
`src/server/shared/DataStores/DBCStructure.h:2284`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
static constexpr std::size_t TaxiMaskSize = 14;
|
||||||
|
typedef std::array<uint32, TaxiMaskSize> TaxiMask;
|
||||||
|
```
|
||||||
|
|
||||||
|
14 × 32 = 448 бит. По одному биту на каждую такситочку: «доступна
|
||||||
|
ли в taxi-сети» и «открыл ли её игрок». Сервер в
|
||||||
|
`ObjectMgr::GetNearestTaxiNode` пропускает любой узел с
|
||||||
|
`(id - 1) / 32 >= 14`, то есть `id > 448`.
|
||||||
|
|
||||||
|
Симптом нарушения: GM создал точку `id = 441`, custom NPC появился,
|
||||||
|
но при выборе destination сервер берёт ближайший **vanilla** узел
|
||||||
|
вместо нашего, строит другую трассу — игрок «крутится по дороге»
|
||||||
|
и в конце сваливается. Видимых ошибок в логе нет.
|
||||||
|
|
||||||
|
Поднять потолок без патча клиента нельзя: пакет `SMSG_SHOWTAXINODES`
|
||||||
|
шлёт ровно 14 uint32, клиент 3.3.5a читает фиксированный размер.
|
||||||
|
|
||||||
|
### Что внутри 1..448
|
||||||
|
|
||||||
|
В реальной vanilla-DBC проекта используется до `id = 440`, и часть
|
||||||
|
номеров в этом диапазоне пустует. Вместе с диапазоном выше vanilla
|
||||||
|
(`441..448`) даёт ~84 свободных слота. Команда `.taxi node free`
|
||||||
|
показывает текущую картину:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.taxi node free
|
||||||
|
Free TaxiNode ids in 1..448: 84 total (8 above-vanilla 441..448, 76 in vanilla gaps).
|
||||||
|
338..339 (2)
|
||||||
|
343..347 (5)
|
||||||
|
349..351 (3)
|
||||||
|
354..356 (3)
|
||||||
|
360..374 (15)
|
||||||
|
378
|
||||||
|
385..391 (7)
|
||||||
|
396..403 (8)
|
||||||
|
406..437 (32)
|
||||||
|
441..448 (8)
|
||||||
|
```
|
||||||
|
|
||||||
|
`.taxi node create` использует двухзонный авто-выбор:
|
||||||
|
|
||||||
|
1. Сначала ищет первый свободный id в `441..448` (выше vanilla,
|
||||||
|
всегда безопасно).
|
||||||
|
2. Если эта зона исчерпана — берёт первый свободный id в дырах
|
||||||
|
`1..440`.
|
||||||
|
|
||||||
|
«Свободный» означает: id не загружен в `sTaxiNodesStore`
|
||||||
|
(merged DBC на сервере) **и** не присутствует в `custom_taxi_nodes`
|
||||||
|
(может быть ещё не задеплоен в DBC).
|
||||||
|
|
||||||
|
### Что делать при исчерпании всех 84 слотов
|
||||||
|
|
||||||
|
- Освободить слот: удалить лишнюю строку из `custom_taxi_nodes`
|
||||||
|
(плюс зависимости в `custom_taxi_paths`).
|
||||||
|
- Перейти на бинарный патч `Wow.exe` под расширенный `TaxiMaskSize`
|
||||||
|
(Cataclysm-эра приватки делают так — увеличивают до 32 uint32 =
|
||||||
|
1024 ноды). Это вне scope модуля: нужен патч exe + изменение
|
||||||
|
`TaxiMaskSize` в ядре + раскатка патча игрокам.
|
||||||
|
- Использовать «фейковые» полёты через скриптового NPC и `MoveSplinePath`,
|
||||||
|
без записи в TaxiMask. Не показывается на карте как обычная точка
|
||||||
|
полёта, но id неограничены. Тоже отдельный модуль.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Правила валидации
|
||||||
|
|
||||||
|
`.taxi path validate <pathId>` применяет следующие правила
|
||||||
|
(пороги — константы в [src/cs_taxi_builder.cpp](src/cs_taxi_builder.cpp),
|
||||||
|
секция «Thresholds per spec section 4»):
|
||||||
|
|
||||||
|
| # | Правило | Уровень |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 4.1 | `from`/`to`-нод существует (custom или vanilla) | error |
|
||||||
|
| 4.1 | у пути есть хотя бы одна waypoint | error |
|
||||||
|
| 4.1 | `node_index` идёт подряд от 0 | error |
|
||||||
|
| 4.2 | минимум 3 точки | error |
|
||||||
|
| 4.2 | рекомендация: 12+ точек | warning |
|
||||||
|
| 4.3 | сегмент `> 200 yd` (XY) | error |
|
||||||
|
| 4.3 | сегмент `> 120 yd` (XY) | warning |
|
||||||
|
| 4.3 | сегмент `< 40 yd` (XY) | warning (`IsNodeIncludedInShortenedPath` режет) |
|
||||||
|
| 4.3 | сегмент пересекает границу карты | error (полёт между континентами невозможен) |
|
||||||
|
| 4.4 | первая точка `> 20 yd` от `FromTaxiNode` | warning |
|
||||||
|
| 4.4 | последняя точка `> 20 yd` от `ToTaxiNode` | warning |
|
||||||
|
| 4.4 | первая/последняя точка на другой карте, чем нод | error |
|
||||||
|
| 4.5 | модуль абсолютной разницы Z последней точки и `ToTaxiNode > 30 yd` | warning (риск падения) |
|
||||||
|
| 4.6 | поворот `> 75°` в плоскости XY между сегментами | warning |
|
||||||
|
|
||||||
|
`.taxi path test` отказывается работать, если есть хотя бы один error.
|
||||||
|
Warnings — индикативны, не блокируют.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Прореживание точек: `moonwell-taxi thin`
|
||||||
|
|
||||||
|
Самая частая поломка свежезаписанного маршрута: в полёте таксИ
|
||||||
|
«крутится по дороге» и сбрасывает игрока на кладбище. Причина —
|
||||||
|
большинство сегментов короче 40 yd. AzerothCore при построении
|
||||||
|
spline-а вызывает `IsNodeIncludedInShortenedPath` (`SKIP_SPLINE_POINT_DISTANCE_SQ = 40 * 40`)
|
||||||
|
и выкидывает waypoint-ы, которые ближе ~40 yd друг к другу. Если у
|
||||||
|
вас 68 точек, среднее расстояние 12 yd — после фильтрации остаётся
|
||||||
|
3-5 точек, по которым spline крутится как угодно.
|
||||||
|
|
||||||
|
Симптом виден прямо в `.taxi path validate <pathId>`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[warn] segment 19->20 is 12.1 yd (< 40 yd, may be filtered by IsNodeIncludedInShortenedPath)
|
||||||
|
[warn] segment 20->21 is 12.7 yd (< 40 yd, ...)
|
||||||
|
...
|
||||||
|
Validation: 0 errors, 67 warnings — OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Чинить руками удалением точек по одной — больно. Внешний CLI имеет
|
||||||
|
подкоманду `thin`, которая делает это безопасно: пишет бэкап,
|
||||||
|
удаляет старые waypoint-ы и записывает прореженные с пересчитанным
|
||||||
|
`node_index` от 0.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. посмотреть, что получится, без записи в БД
|
||||||
|
python3 tools/moonwell-taxi/moonwell_taxi.py thin --path 1979 --min-yd 50 --dry-run
|
||||||
|
|
||||||
|
# 2. реально применить
|
||||||
|
python3 tools/moonwell-taxi/moonwell_taxi.py thin --path 1979 --min-yd 50
|
||||||
|
```
|
||||||
|
|
||||||
|
Параметры:
|
||||||
|
|
||||||
|
- `--path <id>` — обязательный, какой `custom_taxi_paths.id` чистить.
|
||||||
|
- `--min-yd <float>` — минимальное XY-расстояние между соседними
|
||||||
|
оставленными точками. Дефолт `50`. Для длинных прямых смело
|
||||||
|
ставьте `60-80`, для извилистых — `40-50`.
|
||||||
|
- `--dry-run` — показать план без модификации БД.
|
||||||
|
|
||||||
|
Алгоритм:
|
||||||
|
|
||||||
|
1. Первая waypoint всегда сохраняется.
|
||||||
|
2. Каждая последующая сравнивается с последней оставленной по XY.
|
||||||
|
Если ≥ `--min-yd` — сохраняется, иначе пропускается.
|
||||||
|
3. Последняя waypoint всегда сохраняется (анкер посадки).
|
||||||
|
4. Если предпоследняя получилась слишком близко к последней — её
|
||||||
|
тоже выкидывают. Так не возникает «маленький хвост» на финише.
|
||||||
|
5. Все ID `node_index` пересчитываются от `0` без пропусков.
|
||||||
|
6. Перед удалением исходные строки копируются в
|
||||||
|
`custom_taxi_path_nodes_backup_<pathId>_<unix_ts>` — откатить
|
||||||
|
можно прямым SQL (команда сама печатает rollback в конце).
|
||||||
|
|
||||||
|
Пример вывода на боевом маршруте:
|
||||||
|
|
||||||
|
```
|
||||||
|
Path 1979: 68 -> 13 waypoints (min_yd=50.0, removed 55)
|
||||||
|
Backup table: `custom_taxi_path_nodes_backup_1979_1778283612`
|
||||||
|
To rollback:
|
||||||
|
DELETE FROM custom_taxi_path_nodes WHERE path_id = 1979;
|
||||||
|
INSERT INTO custom_taxi_path_nodes
|
||||||
|
SELECT * FROM `custom_taxi_path_nodes_backup_1979_1778283612`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Что делать после `thin`:
|
||||||
|
|
||||||
|
```
|
||||||
|
.taxi path validate 1979 # должно быть 0 errors, 0 warnings про <40 yd
|
||||||
|
.taxi path report 1979 # Min/Max segment в коридоре 40-120 yd
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем `bash tools/moonwell-taxi/redeploy.sh` — экспорт DBC, заливка
|
||||||
|
в `var/client-data-live/dbc/`, рестарт worldserver-а.
|
||||||
|
|
||||||
|
**Важно:** `thin` работает только с записями в `custom_taxi_path_nodes`.
|
||||||
|
DBC сами по себе не меняются — для актуализации сервера нужен
|
||||||
|
`redeploy.sh` (или ручной `export-dbc` + `cp` + `restart`). Без этого
|
||||||
|
in-game проверка увидит старый, плотный маршрут.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура БД
|
||||||
|
|
||||||
|
Все три таблицы в `acore_world`. См.
|
||||||
|
[data/sql/updates/pending_db_world/rev_*.sql](../../data/sql/updates/pending_db_world/).
|
||||||
|
|
||||||
|
```
|
||||||
|
custom_taxi_nodes
|
||||||
|
id int unsigned PRIMARY KEY (модуль выдаёт >= 441)
|
||||||
|
name varchar(100) (utf8mb4, утаивается во все 16 локалей DBC)
|
||||||
|
map_id smallint unsigned
|
||||||
|
position_x float
|
||||||
|
position_y float
|
||||||
|
position_z float
|
||||||
|
mount_alliance int unsigned
|
||||||
|
mount_horde int unsigned
|
||||||
|
created_by bigint unsigned (GUID GM-а на момент создания)
|
||||||
|
created_at timestamp
|
||||||
|
updated_at timestamp
|
||||||
|
|
||||||
|
custom_taxi_paths
|
||||||
|
id int unsigned PRIMARY KEY (модуль выдаёт >= 1979)
|
||||||
|
from_taxi_node_id int unsigned (custom ИЛИ vanilla)
|
||||||
|
to_taxi_node_id int unsigned (custom ИЛИ vanilla)
|
||||||
|
cost int unsigned (медяки)
|
||||||
|
is_enabled tinyint(1)
|
||||||
|
created_by, created_at, updated_at
|
||||||
|
KEY idx_from_to (from_taxi_node_id, to_taxi_node_id)
|
||||||
|
|
||||||
|
custom_taxi_path_nodes
|
||||||
|
id int unsigned AUTO_INCREMENT (внутренний; DBC ID — на экспорте)
|
||||||
|
path_id int unsigned
|
||||||
|
node_index int unsigned (UNIQUE с path_id, 0-based)
|
||||||
|
map_id smallint unsigned
|
||||||
|
position_x, _y, _z float
|
||||||
|
flags int unsigned (TaxiPathNode flags: teleport=1, stop=2 ...)
|
||||||
|
delay int unsigned
|
||||||
|
arrival_event_id int unsigned
|
||||||
|
departure_event_id int unsigned
|
||||||
|
comment varchar(255)
|
||||||
|
created_at timestamp
|
||||||
|
```
|
||||||
|
|
||||||
|
ID-диапазоны: модуль предпочитает `441..448` для новых нод, а когда
|
||||||
|
этот диапазон занят, использует свободные дырки внутри `1..440`.
|
||||||
|
Для путей модуль начинает с `1979`, сразу после vanilla max `1978`.
|
||||||
|
Схема БД эти правила не enforce-ит, поэтому в теории можно вставить ID
|
||||||
|
руками, но дальше CLI откажется писать DBC при коллизии с vanilla.
|
||||||
|
|
||||||
|
`from`/`to` в `custom_taxi_paths` намеренно без FK: маршрут регулярно
|
||||||
|
ведёт от custom-нода к vanilla (как в исходном кейсе ТЗ —
|
||||||
|
«Златоземье → Штормград»).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Связь с CLI moonwell-taxi
|
||||||
|
|
||||||
|
Модуль *только* пишет в БД. DBC генерирует внешний инструмент
|
||||||
|
[tools/moonwell-taxi/](../../tools/moonwell-taxi/):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Прочесть custom_taxi_*, смерджить с vanilla DBC из корня репозитория,
|
||||||
|
# записать оба комплекта (server + client) и проверить sha256.
|
||||||
|
python3 tools/moonwell-taxi/moonwell_taxi.py export-dbc
|
||||||
|
|
||||||
|
# Output:
|
||||||
|
# build/taxi/export/server/dbc/TaxiNodes.dbc
|
||||||
|
# build/taxi/export/server/dbc/TaxiPath.dbc
|
||||||
|
# build/taxi/export/server/dbc/TaxiPathNode.dbc
|
||||||
|
# build/taxi/export/client/DBFilesClient/TaxiNodes.dbc
|
||||||
|
# build/taxi/export/client/DBFilesClient/TaxiPath.dbc
|
||||||
|
# build/taxi/export/client/DBFilesClient/TaxiPathNode.dbc
|
||||||
|
# + sha256.txt в каждом каталоге
|
||||||
|
```
|
||||||
|
|
||||||
|
Если sha256 серверной и клиентской копий не совпадают — CLI выходит с
|
||||||
|
`exit code 1`, не считая экспорт валидным.
|
||||||
|
|
||||||
|
**Важно:** модуль и CLI общаются *только через схему БД*. Никаких
|
||||||
|
общих хедеров, FIFO или RPC. Меняется схема — обновляется и CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Известные ограничения
|
||||||
|
|
||||||
|
- **Только custom_taxi_*, не модифицирует vanilla.** Чтобы поправить
|
||||||
|
существующую такситочку или путь vanilla, нужны другие средства.
|
||||||
|
- **Имя пишется в locale slot текущего vanilla DBC.** Exporter
|
||||||
|
авто-инферит slot и `Name_lang_mask` из `TaxiNodes.dbc` (для ruRU это
|
||||||
|
slot `8`). Per-locale override таблицы пока нет.
|
||||||
|
- **Mount creature ID — авто-подбор по большинству на карте.** Для
|
||||||
|
Outland/Northrend и нестандартных карт fallback `541/541` может
|
||||||
|
выглядеть как Альянсный грифон у Орды. Меняется UPDATE-ом
|
||||||
|
`custom_taxi_nodes.mount_horde`.
|
||||||
|
- **`.taxi markers spawn` спавнит только на текущей карте.** Точки на
|
||||||
|
других континентах пропускаются — таково ограничение grid-load.
|
||||||
|
- **`.taxi path test` не запускает реальный flight.** Реальный полёт
|
||||||
|
работает только через DBC; у `path test` MVP-уровень — телепорт на
|
||||||
|
первую точку. Делать `Player::ActivateTaxiPathTo` для путей не из
|
||||||
|
DBC требует более глубокой интеграции (out of scope MVP).
|
||||||
|
- **Сессия записи не персистится.** Рестарт worldserver сбрасывает
|
||||||
|
состояние `.taxi record`. Workaround: `.taxi record start <pathId>`
|
||||||
|
заново — точки в БД остаются.
|
||||||
|
- **Конвенция Alliance/Horde в DBC обратна тексту ТЗ.** В реальном
|
||||||
|
DBC `MountCreatureID[0]` — Horde, `[1]` — Alliance
|
||||||
|
(см. `ObjectMgr.cpp:7229`). Колонки `mount_alliance` /
|
||||||
|
`mount_horde` в БД названы корректно — конверсия делается при записи
|
||||||
|
в DBC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## FAQ / типичные ошибки
|
||||||
|
|
||||||
|
### «Path 1979 has no waypoints — record some first»
|
||||||
|
|
||||||
|
Создан путь, но не записано ни одной точки. Запусти
|
||||||
|
`.taxi record start 1979`, пройди маршрут, нажимая
|
||||||
|
`.taxi record point` в нужных местах, заверши `.taxi record finish`.
|
||||||
|
|
||||||
|
### «custom_taxi_paths id=N not found»
|
||||||
|
|
||||||
|
Путь не существует или `is_enabled=0`. Список — `.taxi path list`.
|
||||||
|
|
||||||
|
### «from-node N does not exist (custom or vanilla)»
|
||||||
|
|
||||||
|
ID не найден ни в `custom_taxi_nodes`, ни в vanilla `TaxiNodes.dbc`.
|
||||||
|
Список — `.taxi node list <substring>`.
|
||||||
|
|
||||||
|
### «An enabled path X -> Y already exists with id Z»
|
||||||
|
|
||||||
|
Активный маршрут `from→to` уже существует. Либо
|
||||||
|
`UPDATE custom_taxi_paths SET is_enabled=0 WHERE id=Z;`, либо
|
||||||
|
используй существующий маршрут.
|
||||||
|
|
||||||
|
### `.taxi path validate` показывает много `< 40 yd` warnings
|
||||||
|
|
||||||
|
Сегменты слишком короткие — `IsNodeIncludedInShortenedPath` их режет
|
||||||
|
и реальный полёт пройдёт «через» эти точки. Удали лишние через
|
||||||
|
`.taxi record undo` или прямым `DELETE` по `node_index`.
|
||||||
|
|
||||||
|
### Marker GO entry 178559 is missing from gameobject_template
|
||||||
|
|
||||||
|
На сервере зачищен banner GO. Поменяй `MARKER_GO_ENTRY` в
|
||||||
|
[src/cs_taxi_builder.cpp](src/cs_taxi_builder.cpp) на любой видимый
|
||||||
|
GO из `gameobject_template` и пересобери модуль.
|
||||||
|
|
||||||
|
### После `export-dbc` сервер видит точку, а клиент — нет
|
||||||
|
|
||||||
|
Клиент читает свои `DBFilesClient/*.dbc` из MPQ-патчей. Проверь, что
|
||||||
|
файлы из `build/taxi/export/client/DBFilesClient/` попали в
|
||||||
|
подключённый патч (например, `patch-X.MPQ` с приоритетом выше
|
||||||
|
базовых) и удалён клиентский `Cache/`.
|
||||||
|
|
||||||
|
### `Server/client DBC match: FAIL` после экспорта
|
||||||
|
|
||||||
|
Не должно случаться при штатной работе CLI (server и client пишутся
|
||||||
|
из одного буфера). Если всё-таки — баг в инструменте, открывайте
|
||||||
|
issue с `sha256.txt` обоих комплектов.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
MOD_TAXI_BUILDER_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/" && pwd )"
|
||||||
|
|
||||||
|
source $MOD_TAXI_BUILDER_ROOT"/conf/conf.sh.dist"
|
||||||
|
|
||||||
|
if [ -f $MOD_TAXI_BUILDER_ROOT"/conf/conf.sh" ]; then
|
||||||
|
source $MOD_TAXI_BUILDER_ROOT"/conf/conf.sh"
|
||||||
|
fi
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* mod-taxi-builder: verbose runtime logging of taxi-related events.
|
||||||
|
* Hooks creature gossip interactions (catches the moment a player clicks a
|
||||||
|
* flight master — useful when the client crashes on dialog open) and polls
|
||||||
|
* Player::IsInFlight() to log start/mid/end of taxi flights with positions
|
||||||
|
* and current path. Output goes to Server.log via the standard logger; the
|
||||||
|
* channel is `server.loading` so it always passes the default Appender
|
||||||
|
* thresholds, but the `[taxi-log]` prefix lets you grep cleanly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "Chat.h"
|
||||||
|
#include "Creature.h"
|
||||||
|
#include "Log.h"
|
||||||
|
#include "ObjectGuid.h"
|
||||||
|
#include "Player.h"
|
||||||
|
#include "ScriptMgr.h"
|
||||||
|
|
||||||
|
#include <unordered_map>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct TaxiLogState
|
||||||
|
{
|
||||||
|
bool inFlight = false;
|
||||||
|
uint32 lastPath = 0;
|
||||||
|
uint32 nextLogMs = 0;
|
||||||
|
float lastX = 0.f;
|
||||||
|
float lastY = 0.f;
|
||||||
|
float lastZ = 0.f;
|
||||||
|
uint32 lastMapId = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
std::unordered_map<ObjectGuid, TaxiLogState> g_taxiLog;
|
||||||
|
|
||||||
|
constexpr uint32 PROGRESS_LOG_INTERVAL_MS = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
class taxi_builder_gossip_log : public AllCreatureScript
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
taxi_builder_gossip_log() : AllCreatureScript("taxi_builder_gossip_log") { }
|
||||||
|
|
||||||
|
bool CanCreatureGossipHello(Player* player, Creature* creature) override
|
||||||
|
{
|
||||||
|
if (!player || !creature)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
bool const isFlightMaster = creature->IsTaxi();
|
||||||
|
LOG_INFO("server.loading",
|
||||||
|
"[taxi-log] GossipHello: player={} (lvl {}) creature={} entry={} guid={} "
|
||||||
|
"isFlightMaster={} pos=({:.2f},{:.2f},{:.2f}) map={} npcflag=0x{:X}",
|
||||||
|
player->GetName(), uint32(player->GetLevel()),
|
||||||
|
creature->GetName(), creature->GetEntry(),
|
||||||
|
creature->GetGUID().ToString(),
|
||||||
|
isFlightMaster ? "YES" : "no",
|
||||||
|
creature->GetPositionX(), creature->GetPositionY(), creature->GetPositionZ(),
|
||||||
|
creature->GetMapId(),
|
||||||
|
creature->GetUInt32Value(UNIT_NPC_FLAGS));
|
||||||
|
// returning false leaves default handling in place (we just observed)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class taxi_builder_flight_log : public PlayerScript
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
taxi_builder_flight_log() : PlayerScript("taxi_builder_flight_log") { }
|
||||||
|
|
||||||
|
void OnPlayerLogout(Player* player) override
|
||||||
|
{
|
||||||
|
if (player)
|
||||||
|
g_taxiLog.erase(player->GetGUID());
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnPlayerUpdate(Player* player, uint32 diff) override
|
||||||
|
{
|
||||||
|
if (!player)
|
||||||
|
return;
|
||||||
|
|
||||||
|
bool const nowFlying = player->IsInFlight();
|
||||||
|
auto it = g_taxiLog.find(player->GetGUID());
|
||||||
|
|
||||||
|
// Fast path: not flying and never started — skip allocation.
|
||||||
|
if (!nowFlying && it == g_taxiLog.end())
|
||||||
|
return;
|
||||||
|
|
||||||
|
TaxiLogState& state = (it != g_taxiLog.end()) ? it->second : g_taxiLog[player->GetGUID()];
|
||||||
|
|
||||||
|
// START of flight
|
||||||
|
if (nowFlying && !state.inFlight)
|
||||||
|
{
|
||||||
|
uint32 path = player->m_taxi.GetCurrentTaxiPath();
|
||||||
|
auto const& nodes = player->m_taxi.GetPath();
|
||||||
|
std::string nodeList;
|
||||||
|
for (uint32 n : nodes)
|
||||||
|
{
|
||||||
|
if (!nodeList.empty()) nodeList += ",";
|
||||||
|
nodeList += std::to_string(n);
|
||||||
|
}
|
||||||
|
LOG_INFO("server.loading",
|
||||||
|
"[taxi-log] START player={} path={} nodes=[{}] start=({:.1f},{:.1f},{:.1f}) map={}",
|
||||||
|
player->GetName(), path, nodeList,
|
||||||
|
player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
|
||||||
|
player->GetMapId());
|
||||||
|
|
||||||
|
state.inFlight = true;
|
||||||
|
state.lastPath = path;
|
||||||
|
state.lastX = player->GetPositionX();
|
||||||
|
state.lastY = player->GetPositionY();
|
||||||
|
state.lastZ = player->GetPositionZ();
|
||||||
|
state.lastMapId = player->GetMapId();
|
||||||
|
state.nextLogMs = PROGRESS_LOG_INTERVAL_MS;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// END of flight
|
||||||
|
if (!nowFlying && state.inFlight)
|
||||||
|
{
|
||||||
|
LOG_INFO("server.loading",
|
||||||
|
"[taxi-log] END player={} lastPath={} end=({:.1f},{:.1f},{:.1f}) map={}",
|
||||||
|
player->GetName(), state.lastPath,
|
||||||
|
player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
|
||||||
|
player->GetMapId());
|
||||||
|
state.inFlight = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mid-flight throttled progress log (~1s)
|
||||||
|
if (!nowFlying)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (state.nextLogMs > diff)
|
||||||
|
{
|
||||||
|
state.nextLogMs -= diff;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.nextLogMs = PROGRESS_LOG_INTERVAL_MS;
|
||||||
|
|
||||||
|
float dx = player->GetPositionX() - state.lastX;
|
||||||
|
float dy = player->GetPositionY() - state.lastY;
|
||||||
|
float dz = player->GetPositionZ() - state.lastZ;
|
||||||
|
float moved = std::sqrt(dx*dx + dy*dy + dz*dz);
|
||||||
|
|
||||||
|
LOG_INFO("server.loading",
|
||||||
|
"[taxi-log] MID player={} path={} pos=({:.1f},{:.1f},{:.1f}) map={} dMoved={:.1f}yd",
|
||||||
|
player->GetName(), state.lastPath,
|
||||||
|
player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(),
|
||||||
|
player->GetMapId(), moved);
|
||||||
|
|
||||||
|
state.lastX = player->GetPositionX();
|
||||||
|
state.lastY = player->GetPositionY();
|
||||||
|
state.lastZ = player->GetPositionZ();
|
||||||
|
state.lastMapId = player->GetMapId();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void AddSC_taxi_builder_logger()
|
||||||
|
{
|
||||||
|
new taxi_builder_gossip_log();
|
||||||
|
new taxi_builder_flight_log();
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*
|
||||||
|
* mod-taxi-builder entry point. AzerothCore's module loader calls
|
||||||
|
* Addmod_taxi_builderScripts() during startup; this function registers
|
||||||
|
* every script the module owns.
|
||||||
|
*/
|
||||||
|
|
||||||
|
void AddSC_taxi_builder_commandscript();
|
||||||
|
void AddSC_taxi_builder_logger();
|
||||||
|
|
||||||
|
void Addmod_taxi_builderScripts()
|
||||||
|
{
|
||||||
|
AddSC_taxi_builder_commandscript();
|
||||||
|
AddSC_taxi_builder_logger();
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
./__pycache__
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
python3
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
/usr/bin/python3
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
python3
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
lib
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
home = /usr/bin
|
||||||
|
include-system-site-packages = false
|
||||||
|
version = 3.12.3
|
||||||
|
executable = /usr/bin/python3.12
|
||||||
|
command = /usr/bin/python3 -m venv /home/sindo/moonwell-core/tools/moonwell-taxi/.venv
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# moonwell-taxi
|
||||||
|
|
||||||
|
External CLI that reads custom flight data from `acore_world` and the
|
||||||
|
three vanilla taxi DBCs, then generates merged DBCs ready to drop into
|
||||||
|
both the server's `dbc/` directory and the client patch.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. install deps (PyMySQL only)
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 2. point at your vanilla DBC source and run the export
|
||||||
|
./bin/moonwell-taxi export-dbc \
|
||||||
|
--vanilla-dir /home/sindo/moonwell-core \
|
||||||
|
--output-dir /home/sindo/moonwell-core/build/taxi/export
|
||||||
|
|
||||||
|
# Output:
|
||||||
|
# <output-dir>/server/dbc/TaxiNodes.dbc
|
||||||
|
# <output-dir>/server/dbc/TaxiPath.dbc
|
||||||
|
# <output-dir>/server/dbc/TaxiPathNode.dbc
|
||||||
|
# <output-dir>/client/DBFilesClient/TaxiNodes.dbc
|
||||||
|
# <output-dir>/client/DBFilesClient/TaxiPath.dbc
|
||||||
|
# <output-dir>/client/DBFilesClient/TaxiPathNode.dbc
|
||||||
|
#
|
||||||
|
# A sha256 manifest is written next to each half; the tool fails with a
|
||||||
|
# non-zero exit if server and client copies do not match.
|
||||||
|
|
||||||
|
# 3. pack the client half into an MPQ patch for native taxi UI
|
||||||
|
./bin/moonwell-taxi build-client-patch
|
||||||
|
|
||||||
|
# Output:
|
||||||
|
# <output-dir>/client/patch-Z.MPQ
|
||||||
|
#
|
||||||
|
# Copy that MPQ to the WoW 3.3.5a client's Data directory, delete the
|
||||||
|
# client's Cache directory, then restart Wow.exe. Loose DBFilesClient/*.dbc
|
||||||
|
# files are not enough for the stock 3.3.5a client taxi UI.
|
||||||
|
```
|
||||||
|
|
||||||
|
## DB connection
|
||||||
|
|
||||||
|
Reads connection params from environment variables (which `acore.sh` and
|
||||||
|
`import-custom-sql.sh` already populate from `.env`):
|
||||||
|
|
||||||
|
- `DOCKER_DB_EXTERNAL_PORT` (default 3306)
|
||||||
|
- `DOCKER_DB_ROOT_PASSWORD` (default "password")
|
||||||
|
- `MOONWELL_TAXI_DB_HOST` (default 127.0.0.1)
|
||||||
|
- `MOONWELL_TAXI_DB_USER` (default root)
|
||||||
|
- `MOONWELL_TAXI_DB_NAME` (default acore_world)
|
||||||
|
|
||||||
|
CLI flags override env: `--db-host`, `--db-port`, `--db-user`,
|
||||||
|
`--db-password`, `--db-name`.
|
||||||
|
|
||||||
|
## Subcommands
|
||||||
|
|
||||||
|
- `export-dbc` — fully implemented: merges vanilla + custom into new DBCs,
|
||||||
|
writes server and client copies, prints sha256 for both. Refuses to run
|
||||||
|
if `--vanilla-dir` already contains records in the custom-id space
|
||||||
|
(TaxiNode id ≥ 441, TaxiPath id ≥ 1979) — that means the directory
|
||||||
|
was already merged once and re-running would compound rows.
|
||||||
|
- `build-client-patch` — packs the exported client DBCs into a minimal MPQ
|
||||||
|
patch with internal paths `DBFilesClient\TaxiNodes.dbc`,
|
||||||
|
`DBFilesClient\TaxiPath.dbc`, and `DBFilesClient\TaxiPathNode.dbc`.
|
||||||
|
This is required for the native flight-master map UI to see custom
|
||||||
|
taxi nodes; otherwise the client can crash when the server sends a
|
||||||
|
custom current-node id.
|
||||||
|
- `thin --path <id> [--min-yd 50] [--dry-run]` — drops waypoints that sit
|
||||||
|
within `min-yd` (XY) of the previously kept point, preserves first and
|
||||||
|
last, renumbers `node_index` from 0. Use it when an in-game recording
|
||||||
|
produced too-dense waypoints (chat shows lots of
|
||||||
|
`segment N->M is X.X yd (< 40 yd ...)` warnings) — server filters those
|
||||||
|
via `IsNodeIncludedInShortenedPath` and the spline goes haywire.
|
||||||
|
Backs up to `custom_taxi_path_nodes_backup_<pathId>_<ts>` before
|
||||||
|
deleting; the rollback SQL is printed at the end.
|
||||||
|
- `dump-vanilla <path> [--limit N]` — debug helper: parses a `.dbc` file
|
||||||
|
and prints header + first N records as raw uint32. Useful when adding
|
||||||
|
new schemas.
|
||||||
|
|
||||||
|
The other subcommands listed in the spec (build-mpq, deploy-server,
|
||||||
|
backup, rollback, diff) are not part of the MVP and return a
|
||||||
|
not-implemented error today.
|
||||||
|
|
||||||
|
## Default paths
|
||||||
|
|
||||||
|
`--vanilla-dir` defaults to `<repo>/var/client-data-vanilla`. Keep that
|
||||||
|
directory as a clean snapshot. `redeploy.sh` writes merged files into
|
||||||
|
`<repo>/var/client-data-live/dbc`, not back into the vanilla snapshot.
|
||||||
Binary file not shown.
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
HERE="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
|
||||||
|
exec python3 "$HERE/moonwell_taxi.py" "$@"
|
||||||
@@ -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())
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Re-export TaxiNodes / TaxiPath / TaxiPathNode DBCs from custom_taxi_*
|
||||||
|
# tables, copy them into the worldserver's bind-mounted data directory,
|
||||||
|
# and restart the worldserver. Idempotent.
|
||||||
|
#
|
||||||
|
# Vanilla baseline lives in var/client-data-vanilla/ and is treated as
|
||||||
|
# read-only: the export tool reads from there but writes to
|
||||||
|
# build/taxi/export/. Never point --vanilla-dir at the live tree, or
|
||||||
|
# subsequent exports will compound their own custom rows.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||||
|
VANILLA_DIR="${VANILLA_DIR:-$ROOT_DIR/var/client-data-vanilla}"
|
||||||
|
LIVE_DIR="${LIVE_DIR:-$ROOT_DIR/var/client-data-live/dbc}"
|
||||||
|
EXPORT_DIR="${EXPORT_DIR:-$ROOT_DIR/build/taxi/export}"
|
||||||
|
SERVER_OUT="$EXPORT_DIR/server/dbc"
|
||||||
|
|
||||||
|
log() { printf '[redeploy] %s\n' "$*"; }
|
||||||
|
|
||||||
|
if [[ ! -d "$VANILLA_DIR" ]]; then
|
||||||
|
printf 'vanilla snapshot not found: %s\n' "$VANILLA_DIR" >&2
|
||||||
|
printf 'Create it once from a clean live tree:\n' >&2
|
||||||
|
printf ' mkdir -p %s && cp %s/Taxi*.dbc %s/\n' \
|
||||||
|
"$VANILLA_DIR" "$LIVE_DIR" "$VANILLA_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$LIVE_DIR" ]]; then
|
||||||
|
printf 'live dbc dir not found: %s\n' "$LIVE_DIR" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
log "exporting DBCs from $VANILLA_DIR"
|
||||||
|
python3 "$ROOT_DIR/tools/moonwell-taxi/moonwell_taxi.py" export-dbc \
|
||||||
|
--vanilla-dir "$VANILLA_DIR" \
|
||||||
|
--output-dir "$EXPORT_DIR"
|
||||||
|
|
||||||
|
log "copying server DBCs into $LIVE_DIR"
|
||||||
|
cp "$SERVER_OUT/TaxiNodes.dbc" "$LIVE_DIR/"
|
||||||
|
cp "$SERVER_OUT/TaxiPath.dbc" "$LIVE_DIR/"
|
||||||
|
cp "$SERVER_OUT/TaxiPathNode.dbc" "$LIVE_DIR/"
|
||||||
|
|
||||||
|
if docker compose ps --status running --services 2>/dev/null | grep -qx 'ac-worldserver'; then
|
||||||
|
log "restarting ac-worldserver"
|
||||||
|
docker compose restart ac-worldserver
|
||||||
|
else
|
||||||
|
log "ac-worldserver is not running; new DBCs will be picked up on next start"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "done"
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
PyMySQL>=1.1
|
||||||
Reference in New Issue
Block a user