taxi builder

This commit is contained in:
2026-05-11 18:35:47 +04:00
parent 0a3d3b6afa
commit 13747727cf
21 changed files with 3578 additions and 0 deletions
+42
View File
@@ -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)';
+23
View File
@@ -234,6 +234,28 @@ import_aoe_loot_sql() {
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
case "$1" in
-h|--help)
@@ -260,6 +282,7 @@ fi
import_mythicplus_sql
import_aoe_loot_sql
import_taxi_builder_sql
import_store_sql
log "custom SQL bootstrap completed"
+4
View File
@@ -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.
+753
View File
@@ -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
+8
View File
@@ -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();
}
+1
View File
@@ -0,0 +1 @@
python3
+1
View File
@@ -0,0 +1 @@
/usr/bin/python3
+1
View File
@@ -0,0 +1 @@
python3
+1
View File
@@ -0,0 +1 @@
lib
+5
View File
@@ -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
+87
View File
@@ -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.
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
HERE="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )"
exec python3 "$HERE/moonwell_taxi.py" "$@"
+958
View File
@@ -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())
+53
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
PyMySQL>=1.1