# mod-waygate-network — Plan
Fun/QoL project: a discover-as-you-explore fast travel network, in the spirit of FFXIV's Aetheryte
system but built from AzerothCore-native pieces (no reused terminology, no reused Mage spells). See
`README.md` for what's actually built; this file is where the roadmap and open ideas live.
**Design pillar:** additive, not a class-balance change. We deliberately did *not* strip the reagent
off Mage's `Teleport:` spells and hand them to every class — that would quietly devalue a class
identity perk. Instead the network is its own thing, gated only by *where a character has personally
visited*, not tied to any class kit.
## How it works
1. **Attunement.** `PlayerScript::OnPlayerUpdateZone` checks the new zone against the Waygate
destination list (loaded from `mod_waygate_network_locations`, world DB). First time a character
enters one, a row goes into `mod_waygate_network_discovered` (characters DB) and they get a chat
toast (+ optional sound).
2. **Travel.** Typing `.waygate` (a `CommandScript`, `SEC_PLAYER`) immediately summons a short-lived
NPC (`npc_waygate_portal`, entry 911001) next to the player and opens a **two-page** gossip menu
against *it*: continents the character has anything attuned in, then (after picking one) the
attuned destinations within it, with cost. Picking a destination is where the wait happens — a
plain server-controlled `WaygateNetwork.ChannelMs` hold-still timer plus an optional cosmetic
visual, no real spell involved (see "v1.8: dropping the spell-hijack cast" — this replaced the
"v1.7" real-spell-cast-bar approach). See "v1.6: continent grouping" for why it's two pages,
"v1.2: NPC-sourced gossip" below for why a real (if temporary) NPC is involved, and "v1.1: spell →
command pivot" for why the menu isn't itself a spell.
3. **No permanent NPC placement needed.** The portal only ever exists via `Player::SummonCreature`
with `TEMPSUMMON_TIMED_DESPAWN` (15s, or immediately after a destination is picked) — there's no
NPC to place/relocate in the world like `mod-dragon-legacy`'s Aerion.
## Status
- **v1 (in-game tested 2026-08-13):** originally shipped as a self-cast spell (`Call Waygate`,
910000) taught to every class on login. In-game testing found it was learned correctly server-side
but never appeared in the spellbook and couldn't be cast by name or macro — see "v1.1" below.
- **v1.1 (in-game tested same day):** spell replaced with a `.waygate` chat command that opened
gossip sourced from the player's own GUID. Attunement itself worked (confirmed via DB), but the
gossip menu never visibly appeared, and picking never happened — yet the player still landed in
Ironforge every time. See "v1.2" below.
- **v1.2 (in-game tested and confirmed working 2026-08-13):** the command summons a real (if
short-lived) NPC and sources gossip from it instead of the player. Confirmed working end-to-end.
- **v1.3 (in-game tested and confirmed working 2026-08-14):** destinations moved out of hardcoded
C++ into `mod_waygate_network_locations` (world DB), loaded once at startup via
`WaygateNetwork_World::OnLoadCustomDatabaseTable`. The 8 original capitals are seeded at their
original IDs 1-8 so existing `mod_waygate_network_discovered` rows keep resolving correctly.
- **v1.4 (in-game tested and confirmed working 2026-08-14):** admin/debug tooling (`.waygate reload`,
`WaygateNetwork.Debug` show-all mode) plus items 5 and 6 from "Next steps" — sound-on-unlock
mechanism and the first-attunement macro tip. Sound is wired up (`SMSG_PLAY_SOUND` via
`Player::SendDirectMessage`, no dedicated helper exists in this core) but shipped disabled at first
(`WaygateNetwork.UnlockSoundId = 0`) — `soundentries_dbc` is completely empty (0 rows) in the local
dev world DB used while building this, so no sound ID could actually be verified against it at the
time. Fixed in v1.9 — see "v1.9: unlock sound" below for how a real ID was found via the binary DBC.
- **v1.5 (in-game tested and confirmed working 2026-08-14):** item 4 from "Next steps" —
distance-scaled gold cost, gossip text showing gold/silver/copper with coin icons. Real Blizzard
flight prices were checked directly against this client's `TaxiPath.dbc` (not guessed) — see item 4
below and the "Travel cost" section of README.md for the actual numbers and how the defaults were
derived from them.
- **v1.6 (in-game tested and confirmed working 2026-08-15):** first two non-capital destinations —
Shattrath City (Outland, ID 9) and Dalaran (Northrend, ID 10) — plus continent grouping:
`.waygate` is a two-page menu (continents, then destinations within one), since a flat list stops
scaling once it's not just 8 capitals. See "v1.6: continent grouping" below. "I changed my
mind..." was subsequently trimmed to the continent page only, since "« Back" already covers
leaving the destination page.
- **v1.7 (in-game tested and confirmed working end-to-end 2026-08-14):** item 3 from "Next steps" —
a real 10s cast time, borrowed from the real Hearthstone spell (ID 8690) purely for its genuine
cast bar/interrupt behavior. Went through several rounds in-game the same day: originally used the
Mage `Teleport:` family, swapped to Hearthstone after further exploration turned up something
strictly better (universal, no reagent, no cosmetic city mismatch); the cast then didn't actually
show a bar in-game at all, which took a restructure (cast now fires at `.waygate` itself, opening
the waygate, rather than after picking a destination) plus temporary debug logging to finally
confirm — turned out the "no cast bar" reports traced back to the GM test character's own real
Hearthstone accumulating real cooldowns from earlier test attempts, not a deeper bug; a
newly-created character showed the real 10s cast working exactly as intended from the start. Also
fixed along the way: `.waygate` was putting the player's *actual* Hearthstone item on its real
~30-minute cooldown, fixed with `TRIGGERED_IGNORE_SPELL_AND_CATEGORY_CD`. See "v1.7: real cast
time" below for the full blow-by-blow, including a corrected wrong claim from the v1.1 writeup and
a danger in the "just borrow a spell's cast bar" idea that needed an actual fix, not just a
disclaimer.
- **v1.8 (in-game tested 2026-08-15, abandoned the v1.7 approach):** further in-game testing of the
Hearthstone-hijack cast found `PreventHitEffect` wasn't a reliable guarantee — sometimes the real
"teleport to bound hearth location" effect fired anyway, and sometimes the real ~30-minute
cooldown leaked despite `TRIGGERED_IGNORE_SPELL_AND_CATEGORY_CD`, both intermittently rather than
every time. User's own conclusion: "Starting to think it's not a good idea to use an actual spell,
seems like it's hard to override its effect." Replaced entirely with a plain, fully
server-controlled wait — no real spell, no `SpellScript`, nothing to override: the gossip menu now
opens instantly on `.waygate`, and picking a destination triggers a message + an optional cosmetic
`SendPlaySpellVisual` burst + a `WaygateNetwork.ChannelMs` timer via the player's own event
scheduler, with a distance-moved/combat check gating whether it actually completes. See "v1.8:
dropping the spell-hijack cast" below.
- **v1.9 (in-game tested and confirmed working 2026-08-15):** two follow-up polish items on the
v1.8 channel wait, plus the deferred sound-on-unlock ID from item 5 of "Next steps" finally picked.
A looping `EMOTE_STATE_SPELL_CHANNEL_OMNI` animation now plays on the player for the duration of
the wait (cleared unconditionally once it ends, any path), and `WaygateNetwork.ChannelVisualKitId`
defaults to kit 267 and `WaygateNetwork.UnlockSoundId` defaults to 1519 — both real values traced
through this client's binary DBCs rather than left at 0/disabled. See "v1.9: animation + confirmed
visual/sound IDs" below.
- **v1.10:** faction gating, ahead of resuming item 2 ("More locations") — flagged as a real gap
before curating further destinations, since some of the candidate quest hubs (Hillsbrad Foothills'
Southshore/Tarren Mill split, most obviously) are faction-owned towns a character of the wrong
faction would just get killed walking into. New `faction` column
(`ENUM('Alliance','Horde','Neutral')`) on `mod_waygate_network_locations`; `WaygateFactionAllowed`
filters the menu and re-validates in `BeginWaygateChannel`/`FinishWaygateChannel`, same pattern as
the existing attunement check. GM debug mode is the only override (reuses the existing
`IsWaygateDebugFor` bypass, no new permission concept). See "v1.10: faction gating" below.
- **v1.11:** first real content batch for item 2 ("More locations") — 17 new Eastern Kingdoms
destinations (IDs 11-27), one major quest-hub town per zone, each with a real, verified position
(a real innkeeper NPC's actual spawn) and a real `faction` tag. See "v1.11: Eastern Kingdoms
quest-hub batch" below for the method (nearest-graveyard zone matching + `FactionTemplate.dbc`
faction classification) and the two flagged names worth an in-game glance
(Revantusk Village, Menethil Harbor).
- **v1.12:** second continent for item 2 — 18 new Kalimdor destinations (IDs 28-45). Same method as
v1.11, refined with one extra step: matching each innkeeper against the *specific* named graveyard
nearest it (the `Comment` field often already spells out the real town name, e.g. "Durotar, Razor
Hill") instead of just resolving the zone and naming the hub from memory — every name in this
batch is DB-confirmed, no flagged/inferred names this time. See "v1.12: Kalimdor quest-hub batch"
below, including what got skipped and why (Stonetalon Mountains left out entirely — no confident
name match).
- **v1.13:** third continent for item 2 — 16 new Outland destinations (IDs 46-61). Same method as
v1.11/v1.12, with three names flagged as inferred rather than DB-confirmed (Garadar, Allerian
Stronghold, Stonebreaker Hold — the nearest named graveyard was a generic label, not a settlement
name, for these three). Also caught a real contradiction between the two verification signals this
batch relies on (an NPC's resolved faction disagreeing with its matched graveyard's known real
faction) and left that one out rather than assert either way. See "v1.13: Outland quest-hub batch"
below.
- **v1.14:** fourth and final continent for item 2 — 13 new Northrend destinations (IDs 62-74),
completing full quest-hub coverage across all of WotLK. Three more inferred names (Conquest Hold,
K3, The Argent Stand). Caught a second real red flag this round, different from v1.13's: a
promising Icecrown candidate (the Argent Tournament Grounds) resolved to an `AreaTable.dbc` zone
id that's itself tagged to a *different map* than the one the creatures actually spawn on —
ambiguous enough about what a real player's reported zone would be there that it was left out
rather than risk a destination that silently never attunes. See "v1.14: Northrend quest-hub batch"
below.
- **v1.15 (confirmed working end-to-end in-game, 2026-08-15):** item 7 ("Short intro quest line +
lore") — a 6-quest, dialogue-only, no-combat chain, "The Ley Wardens," that closes out the module's
original design pillar question of *why* a character can use the network at all. A real, deliberate
behavior change: `.waygate` now does nothing until the chain's finale is complete
(`WaygateNetwork.RequireIntroQuest`, GM debug mode the only bypass) — passive attunement stays
completely unaffected. Five new persistently-placed NPCs, all real-coordinate-verified; zero new
`CreatureScript` code needed, the gate is a single `Player::GetQuestRewardStatus` guard clause. See
"v1.15: The Ley Wardens" below for the full design and the plan-mode research it came out of.
- **v1.16 (confirmed working end-to-end in-game, 2026-08-15, after two real bug fixes):** item 8
("Interactable attunement objects") — a real, physical Waygate Stone at each of the 74
destinations, replacing the earlier walk-into-the-zone auto-attunement. Went through two rounds of
real, in-game-testing-caught bugs before working: the first object pick had a `displayId` invalid
on this server's actual (reduced) DBC build, and its replacement turned out to be the wrong
GameObject *type* to be player-interactable at all. See "v1.16: Waygate Stones" below for the full
story, including the Titan-flavored alternates considered and rejected along the way.
- **v1.17:** cosmetic follow-up requested once v1.15/v1.16 were both confirmed working — every stone
(and 3 of the 5 Ley Wardens NPCs) was spawning clipped inside/underneath the landmark NPC its
position was originally sourced from; fixed with a small position offset. Also added a one-shot
cosmetic flash on successful attunement (`WaygateNetwork.AttuneVisualKitId`). See "v1.17: cosmetic
polish" below.
- **v1.18:** the Waygate Stones themselves are now gated the same way `.waygate` already was —
interacting with one before the Ley Wardens chain is complete shows in-world flavor text instead of
attuning. Investigated and ruled out true invisibility first: AzerothCore has a
`CONDITION_SOURCE_TYPE_OBJECT_VISIBILITY` condition type that looks purpose-built for exactly this,
but it turns out to be defined and load-time-validated only — genuinely dead, unwired
infrastructure in this codebase, not something to reach for. See "v1.18: gating the stones" below.
- **v1.19:** small follow-up to v1.17's attunement flash — added a one-shot animation alongside it
(`player->HandleEmoteCommand(EMOTE_ONESHOT_USE_STANDING)`, the real generic "reach out and use a
standing object" gesture), since the visual kit alone read as a bit thin for the moment. No config
toggle added — a minor animation paired with an already-configurable visual didn't seem worth its
own on/off switch.
- **v1.20 (2026-08-16):** full redesign of "The Ley Wardens" — confined entirely to Eastern Kingdoms
and Kalimdor (no more Outland/Northrend legs), retuned for an early unlock (`MinLevel 15`,
`QuestLevel 20`, paced for most characters to finish around level 20 rather than 80), and given its
first real combat objective: a Titan-built guardian, "The Ironbound Sentinel," that wakes near
Uldaman to contest the old ley magic being surfaced. Shrinks from 6 quests to 5 (one fewer
intermediate NPC node), retires the now-unused finale quest ID and the "Nazrek the Bent" NPC
entirely, and repurposes "Echo of the Ley Wardens" in place into a new Uldaman contact rather than
discarding that NPC slot. See "v1.20: The Ley Wardens redesign" below for the full write-up.
- **v1.21:** first real in-game feedback on the zone-entry hint's known unthrottled behavior (see
v1.16's "`OnPlayerUpdateZone` rework" note) — several `"You sense a waygate stone somewhere
nearby..."` messages firing back to back. Root cause turned out to be a single taxi flight crossing
multiple un-attuned destination zones in quick succession, not one zone re-triggering the hook.
Fixed with two guards: skip entirely while `player->IsInFlight()`, and dedupe against the last
destination actually hinted via a small `Player::CustomData` entry (session-scoped, not persisted).
See "v1.21: hint spam fix" below.
- **v1.22 (2026-08-19/20):** first real in-game walkthrough of v1.20's relocated NPCs turned up two
real findings, one per NPC/quest-data category. Aemos was spawned but invisible at his new
Hillsbrad Foothills position — the heuristic z-height estimated from nearby spawns 15-19 yards away
was off by ~43 yards (real ground there is ~10.7, not ~53.4), leaving him buried in terrain despite
the quest "!" still showing (the creature was genuinely spawned and in range, just underground).
Fixed live via a real GM `.npc move`, then captured in a follow-up migration. Separately, a quest
already in a character's log from *before* the redesign shipped kept showing entirely stale content
(the old "Echoes in Stone"/Silithus text) even after a full server restart confirmed the new
`quest_template` data was live in the DB — a real, WotLK-client-specific behavior (local `WDB`
quest-text cache, keyed by quest ID, not reliably invalidated by a server-side content change to
the same ID), not a server bug. Fixed by clearing the client's `WDB` cache folder and relogging.
See "v1.22: first in-game walkthrough findings" below for both write-ups in full.
- **v1.23 (2026-08-21):** the last item on the pre-1.0 punch list — "Next steps" item 9, hand-tuning
every Waygate Stone's position — is done. The user walked all 74 stones in-game (`.gobject near` to
find each one, `.gps` for real coordinates) and fed the results back in batches; all 74
`gameobject` rows (`913001-913074`) now have real, individually-verified positions rather than
v1.17's blind 3-yard-offset heuristic. See "v1.23: manual stone repositioning" below.
- **v1.24 (2026-08-21):** two follow-ups from testing v1.23's repositioned stones. First, a real gap
the user caught: `.waygate` still teleported players to each destination's *original* coordinates
in `mod_waygate_network_locations`, never updated to track where the stones actually moved to —
fixed by computing a point 1 yard in front of each stone (a deterministic offset from data already
in hand, not another manual walkthrough) and applying it via `.waygate reload` live, no restart
needed. Second, a false start: initially misread the ask as "stones should open a travel menu on
interaction" and built that, then reverted it once corrected — the actual ask was about the
teleport *destination*, not stone interaction behavior. Also confirmed the reported "stone
orientation didn't stick" is the same "already-spawned live objects don't hot-reload their own
spawn data from a raw DB write" class of issue v1.22 hit with Aemos's position (needs a restart,
not a deeper engine bug) — see "v1.24: destination sync + stone rotation follow-up" below.
- **v1.25 (2026-08-21):** after restarting to rule out v1.24's stale-data theory, the orientation
mismatch persisted — the user checked several stones and found the same fixed gap each time,
confirming it as a real +90-degree offset baked into the "Ancient Stone Marker" model's own mesh,
not a data or reload problem. Fixed by storing 90 degrees less on every stone's `orientation`
(`data/sql/db-world/updates/2026_08_21_04.sql`) so the mesh's own offset lands the visual facing
back on the direction each `.gps` reading originally intended. `mod_waygate_network_locations`
deliberately untouched — its destination-offset math already used the pre-correction value and was
separately confirmed correct. See "v1.25: mesh orientation offset" below.
### v1.1: spell → command pivot
AzerothCore's `spell_dbc` SQL table is the *server's* internal spell database — it has no
relationship to the *client's* own local `Spell.dbc` file, which ships inside the WoW client install
and is what the spellbook/tooltip/macro system actually renders from. A from-scratch ID like 910000
exists nowhere in the client's copy, so the client has nothing to display for it — no name, no icon,
no macro resolution — regardless of how correctly the server tracks that a character knows it. This
server doesn't distribute a client-side patch, so brand-new custom spell IDs are a dead end for
anything player-facing without one. A `CommandScript` has no such dependency (chat commands are
handled server-side only), so it sidesteps the problem entirely.
This is a real constraint worth remembering for **any** future module content on this server that
wants a genuinely new spell ID (not just this one) — see "Deferred / rejected" below for what it'd
actually take to do a real custom spell, and note that `mod-dragon-legacy`'s Dragon Form (900000) and
Whelp Breath (900001) use the exact same from-scratch-ID pattern and haven't been in-game verified
either — worth testing there too before assuming it works.
### v1.2: NPC-sourced gossip
v1.1's `.waygate` opened `SendGossipMenuFor(player, WAYGATE_NETWORK_GOSSIP_TEXT, player->GetGUID())`
— gossip sourced from the player's own GUID. The core's `HandleGossipSelectOptionOpcode` does
explicitly support this server-side (`guid.IsPlayer()` branch, verified against
`src/server/game/Handlers/MiscHandler.cpp`), so this looked sound on paper and was documented as
such in the v1.1 writeup above. In-game testing showed otherwise: no gossip window ever appeared,
but a selection still silently resolved (always landing on Ironforge, the only attuned destination
at the time). Best working theory: every other gossip flow in the game is sourced from a
Creature/GameObject the player has actually interacted with; a menu sourced from the player's own
character, opened out of nowhere by a chat command with no prior click, isn't something the client's
Gossip UI (FrameXML) appears built to handle, even though the network protocol nominally allows it.
This was never actually verified against a real client before v1.1 shipped — only the server-side
half of the round trip was checked, which was a gap in "verification" worth remembering: **passing a
server-side code read doesn't confirm client-side rendering.** Fixed in v1.2 by summoning a real NPC
(`npc_waygate_portal`) and sourcing the menu from it — the exhaustively-proven-working path, since
every vendor/questgiver in the game already validates that a real Creature-sourced gossip menu
renders correctly.
### v1.6: continent grouping
Adding Shattrath (Outland) and Dalaran (Northrend) made the flat destination list start to feel
like it wouldn't scale to a real quest-hub-per-zone list (item 2 below) — user's call: split into a
continent picker first, then destinations within it. Implementation notes:
- **Continent is a curated column, not derived from `map`.** Exodar and Silvermoon share `map = 530`
with genuine Outland zones purely as a client-data quirk (their actual continents got bundled onto
that map ID by Blizzard) — grouping by raw `map` would put them in a nonsensical "Outland" bucket.
`mod_waygate_network_locations.continent` is an `ENUM('Eastern Kingdoms','Kalimdor','Outland',
'Northrend')` set explicitly per row instead. See README's "Continent grouping" section.
- **Two gossip "pages," same NPC, same menu/text id, told apart by `sender`.** Continent picks use
`GOSSIP_SENDER_WAYGATE_CONTINENT`, destination picks use `GOSSIP_SENDER_WAYGATE_DESTINATION`.
Standard, well-understood gossip navigation pattern — `OnGossipSelect` just calls
`ClearGossipMenuFor` + rebuild + `SendGossipMenuFor` again instead of `CloseGossipMenuFor`, same as
any other multi-page NPC menu in the game. No new client-rendering risk like v1.1/v1.2's — this
reuses the same real-NPC-sourced gossip mechanism v1.2 already proved works, just called twice per
interaction instead of once.
- **Continent action codes are fixed 1-based indices** into a hardcoded `WaygateContinents` list
(`{"Eastern Kingdoms","Kalimdor","Outland","Northrend"}`), not a per-request dynamic index — stays
stable regardless of which continents a given character actually has anything attuned in.
- **`GOSSIP_ACTION_BACK`** is `std::numeric_limits::max()`, a destination-page-only sentinel
chosen to sit nowhere near real destination ids. Action `0` still means "close everything" on
either page (unchanged from before).
### v1.7: real cast time
**Corrects an earlier open question rather than a shipped bug** — the "instant" cast time floated
for the original spell 910000 (back when `.waygate` was still spell-based, long since retired) was
never actually load-bearing in anything currently shipped, but the real numbers are worth recording
now that they were actually checked:
- **Real cast times, parsed directly from this client's binary `Spell.dbc`** (this local dev DB's
`spell_dbc`/`spellcasttimes_dbc` SQL tables are too trimmed to trust for real player-only spells —
an earlier query against `spell_dbc` for "Teleport:"-named spells matched decoy/NPC spells, not the
real Mage ones, which was the source of the original wrong "instant" assumption). `Teleport:
` and `Portal: ` are both **real 10.0s casts** (`CastingTimeIndex` 7 → `SpellCastTimes.dbc`
`Base` 10000ms), not instant. Teleport is interruptible by movement, damage only pushes the cast
back (doesn't abort it), no spell-level cooldown. Portal adds abort-on-damage and its own 60s
cooldown.
- **First picked Teleport, then swapped to Hearthstone (ID 8690) after further exploration** —
same 10.0s real cast time, but strictly better on every other axis, all verified against the
binary DBC rather than assumed:
- **Universal**, not faction-specific — every character has it from level 1, so the
Alliance/Horde donor-selection branch (`GetWaygateDonorSpell`) that Teleport needed is gone
entirely, down to one constant (`SPELL_HEARTHSTONE_DONOR`).
- **Zero mana cost, zero reagent** (`Reagent1 = 0`, `ManaCost = 0`) — simpler than Teleport, which
needed `TRIGGERED_IGNORE_POWER_AND_REAGENT_COST` to bypass a real reagent (Rune of Teleportation).
- **Aborts outright on taking damage** (`InterruptFlags` includes the abort-on-damage bit, same as
Portal had) rather than Teleport's push-back-only behavior — a better fit for "can't cheese this
in combat" than what was originally picked.
- **Solves the cosmetic city-mismatch entirely.** Teleport's cast bar always showed one fixed hub
city regardless of actual destination (e.g. heading to Shattrath still read "Teleport:
Stormwind"), noted as an accepted limitation at the time. Hearthstone's cast bar just says
"Hearthstone" — generic, not tied to any city, and thematically it's *already* "recall to a
known place," arguably a better fit for this module's whole premise than a Mage class spell was.
- Investigated (and ruled out) along the way: monster/NPC "Teleport"-named spells (universal,
faction-neutral, no reagent, but every single one is instant — 0ms, defeating the entire point);
generic "Casting"/"Channeling"/"Focus"/"Ritual"-named spells (same result — nothing combines a
real multi-second duration with a workable self-teleport effect except class spells).
- **The real danger this all turned on: naively `CastSpell()`-ing a real spell doesn't just borrow
its cast bar — it runs the entire real spell.** For Teleport, the caster would get yanked to that
spell's own hardcoded city (from `spell_target_position`, not wherever they picked in the menu). For
Hearthstone specifically, its real effect targets `TARGET_DEST_HOME` (the character's bound
hearth location) rather than anywhere useful to us. For Portal (not used, but investigated), it's
worse still: it actually **summons a real, functional portal object** anyone nearby could walk
through for a free ride — a real exploit, not a cosmetic mismatch. This ruled out the original
sketch ("cast the donor, hook something like `AfterCast`") as written — it needed an actual fix,
not just a disclaimer.
- **The fix:** `spell_waygate_network_teleport` is a `SpellScript` attached to the real donor spell ID
(`SPELL_HEARTHSTONE_DONOR` = 8690) via `spell_script_names`, but it's **conditional**: it only does
anything when it finds a pending entry in `PendingWaygateOpens` for that caster (set by
`OpenWaygate` right before casting). A real Hearthstone cast — anyone, any time, for real
reasons — has no pending entry, so the hook returns immediately and the real spell behaves exactly
as Blizzard shipped it. This is a materially different, safer thing than the "must not modify the
donor's own `spell_dbc` row" constraint from v1.1's writeup was originally guarding against (that
constraint is about not changing the donor's *mechanical data*; this is a *conditional* C++ hook
that's a no-op for anyone except this module's own casts. Both are satisfied — `spell_dbc` for 8690
is untouched, and the hook itself only ever fires our logic when we set the marker).
- **Combat block, checked at the one point where it matters.** The original spell-based v1 used
`SPELL_ATTR0_NOT_IN_COMBAT_ONLY_PEACEFUL` to stop `.waygate` being a free escape button; that
protection was lost when it became a plain command (commands don't have spell attributes).
Re-added as an explicit `player->IsInCombat()` check, once, in `OpenWaygate` before the cast
starts, and again in the `SpellScript` hook right after it completes (in case combat started
mid-cast). No third check needed at the actual destination pick — that's fully synchronous now
(see the "Bug found in-game" note below), so there's no gap left for combat to start during it.
- **`PendingWaygateOpens` is keyed by `ObjectGuid::LowType`** (`GetGUID().GetCounter()`), matching
this file's existing convention — core doesn't provide a `std::hash` specialization,
and every other guid-keyed map in this codebase uses the raw counter for the same reason. Entries
carry a 12s expiry as a safety net for an interrupted cast (the real interrupt flags allow movement
to cancel it, which never triggers our completion hook) — without it, a stale, unconsumed entry
could in principle hijack a *later*, genuine same-donor-spell cast by a real player. A fresh
`.waygate` attempt overwrites any previous entry for that player anyway, so the expiry only matters
for that narrow interrupted-and-nothing-reused-the-slot window.
- **Debug mode (GM) deliberately skips all of this** — stays instant, free, no combat/attunement/cost
checks, same as before. It's for reviewing/testing locations quickly, not simulating the real
player experience.
**Bug found in-game after this shipped — two fix attempts, second one is what actually worked:**
1. **First theory (wrong, or at best incomplete):** the original `BeginWaygateTravel` cast the donor
with `TRIGGERED_IGNORE_POWER_AND_REAGENT_COST` — left over from the Teleport-donor days (which had
a real reagent to bypass), never reconsidered after switching to Hearthstone (which has none).
In-game testing showed no visible cast bar at all — it just resolved. Read `Spell.cpp` and found
`Spell::IsNeedSendToClient()` skips the cast-bar packet for a triggered spell unless it has a
`SpellVisual` assigned, and dropped the trigger flag (`TRIGGERED_NONE`, the default) as the fix.
**This did not actually fix it** — re-tested in-game, still no cast bar. Went back and confirmed
directly against the binary `Spell.dbc` that Hearthstone's `SpellVisual[0] = 220` (non-zero), so
that specific gating condition was never actually true in the first place; the theory was
incomplete or wrong. The trigger-flag removal was still kept (nothing needed it — Hearthstone has
zero mana/reagent cost — so there was no reason to revert it), just not the actual fix.
2. **What actually worked: restructure, not a targeted fix.** Root cause was never conclusively
pinned down (code-read alone couldn't reproduce/observe actual client behavior — see the running
theme in this file about the limits of that). User's own suggestion: move the cast to the *start*
of the flow — `.waygate` itself now casts, and only once that completes does anything
gossip-related happen at all (portal summon + menu). Previously the cast fired *from inside* an
`OnGossipSelect` handler, i.e. immediately after a `CloseGossipMenuFor` call in the same handler —
plausible that closing a gossip window and opening a cast bar in the same instant confuses the
client, though this was never confirmed, only worked around. New flow:
- `.waygate` → (combat check) → cast Hearthstone (`OpenWaygate`). No destination involved yet.
- Cast completes → `spell_waygate_network_teleport::HandleOpen` (renamed from `HandleTeleport`)
summons the portal and shows the continent menu — nothing to charge yet.
- Picking a destination from here on is **entirely synchronous** — `CompleteWaygateTravel`
validates attunement/afford, charges, and teleports immediately. No second cast.
- `PendingWaygateCasts` (guid → `{destinationId, expiry}`) simplified to `PendingWaygateOpens`
(guid → expiry only), since the cast is no longer destination-specific.
- Thematically this reads better too, arguably more FFXIV-Aetheryte-like: channel to *open* the
waygate, then step through and pick where to go, rather than pick first and channel per-trip.
- Combat is now checked once, at `OpenWaygate` time, rather than twice (start + completion) —
there's no longer a meaningful gap between "cast completes" and "pick a destination" for combat
to sneak in during, since picking is instant once the menu's up.
- **Not fully explained, just resolved.** If this exact "cast fired from inside a gossip handler"
pattern comes up again in a future module, treat it as a real, reproducible risk worth avoiding
by design (start casts from a command/hook that isn't itself mid-gossip-interaction) rather than
assuming a code-level fix like the trigger-flag one above will be enough.
3. **Confirmed working end-to-end on a fresh character (2026-08-14), plus one more real bug found
and fixed the same session.** Added temporary `LOG_INFO` timestamps around the cast request and
the effect hook to get hard data instead of more guessing. Result on a newly-created character:
`CastSpell()` returned `SPELL_CAST_OK` and `HandleOpen` fired **exactly 10000ms later** — proof
the real cast timing works correctly end to end. (Debug logging removed once confirmed — this was
never meant to ship.)
- **The GM test character's earlier "no cast" reports turned out to be self-inflicted, not a
client-rendering issue at all.** The same debug logging caught later attempts on that character
returning `SPELL_FAILED_NOT_READY` (spell on cooldown) — because every *earlier* test attempt,
before this fix, was putting the caster's **real** Hearthstone item on its real ~30-minute
cooldown (see next bullet), so repeated testing on the same character eventually started
legitimately failing. The exact mechanism behind the very first "instant teleport, no cast bar"
report on that character was never conclusively isolated in the logs, but everything
reproducible now traces back to this same cooldown issue, not a deeper bug in the cast/hook
mechanism itself.
- **Real bug: `.waygate` was putting the player's actual Hearthstone item on cooldown.** The
initial `CastSpell(player, SPELL_HEARTHSTONE_DONOR)` call (no trigger flags) applies the
donor's real cooldown to the caster just like a genuine use would — WotLK's real Hearthstone
enforces its ~30min cooldown via `CategoryRecoveryTime` (a shared category cooldown across
"hearth-like" items), not `RecoveryTime` (which is 0). Fixed by adding
`TRIGGERED_IGNORE_SPELL_AND_CATEGORY_CD` to the cast — confirmed via the same logging that this
doesn't affect cast time/interrupt behavior (the 10000ms-later firing still held). If you test
on a character that was used for testing *before* this fix landed, its real Hearthstone may
still show a lingering real cooldown from those earlier casts — that's expected leftover state,
not a new bug, and wears off on its own.
### v1.8: dropping the spell-hijack cast
v1.7's whole premise was that hijacking a real spell (Hearthstone) got a genuine cast bar for free,
as long as the donor's real effect and cooldown were reliably suppressed for our own triggered
casts. In-game testing after v1.7 shipped found that guarantee didn't hold:
- **"Sadly it seems a bit random if it's actually triggering the gossip or not. Also it does move
the character to their hearthstone place still, and sometimes still triggering the cooldown on the
hearthstone."** — user's exact report. `PreventHitEffect(effIndex)` inside
`spell_waygate_network_teleport::HandleOpen` was supposed to unconditionally suppress Hearthstone's
real `SPELL_EFFECT_TELEPORT_UNITS` effect for our own triggered cast (gated on a pending-entry
marker so a genuine player cast was never touched), and
`TRIGGERED_IGNORE_SPELL_AND_CATEGORY_CD` was supposed to unconditionally stop the real item
cooldown from being applied. Both held during earlier isolated testing (see the "Confirmed working
end-to-end" bullet under v1.7 above) but not consistently under more realistic play — timing/order
effects between the effect hook, the cooldown application, and the gossip-menu open were never
fully pinned down, and weren't worth chasing further once a same-effort alternative existed.
- **Root cause never fully isolated — same "the client/spell system is a black box from a
code-read alone" theme as v1.7's original "no cast bar" bug.** Unlike that earlier bug, this one
didn't yield to a single fix (trigger flags, restructuring cast order) — it reads as an inherent
reliability problem with using `PreventHitEffect` + a conditional guard to override *part* of a
real spell's behavior while relying on the rest (its cast bar) to work normally. Filed here as a
standing lesson: **overriding one piece of a real spell's behavior while keeping another piece
(the cast bar) is not a safe pattern to reach for again** — either use the spell entirely as-is, or
don't use it at all.
- **The replacement, agreed with the user:** open the gossip menu instantly on `.waygate` (no wait at
the front at all), and move the wait to *after* picking a destination — "tells you to stay still
and an effect plays for 3 or 4 seconds" (user's own framing). No real spell anywhere in the flow:
- `BeginWaygateChannel` re-validates attunement/combat/afford (same checks v1.7's `OpenWaygate`
did), sends a chat message, plays an optional cosmetic visual
(`Unit::SendPlaySpellVisual(visualKitId)` — a bare `SMSG_PLAY_SPELL_VISUAL` packet, no aura, no
cooldown, nothing that could leak or need suppressing), then schedules `FinishWaygateChannel` via
`player->m_Events.AddEventAtOffset(lambda, Milliseconds(WaygateNetwork.ChannelMs))` —
`EventProcessor`, the same mechanism `CreatureAI`'s `EventMap`/`TaskScheduler` build on, just used
directly off a `Player` instead of a creature. Captures the player by `ObjectGuid` (re-resolved
via `ObjectAccessor::FindPlayer` when the timer fires) and the destination by value, not by
pointer/reference, per this codebase's standing rule against holding raw `Player*`/data pointers
across a delay.
- `FinishWaygateChannel` re-checks everything (moved-too-far via straight-line distance from the
captured start position, same-map, combat, module-enabled, still-attuned, still-affordable) before
actually charging and teleporting — the module could've been disabled, the character could've
spent their gold, or destinations could've been reloaded during the few-second wait.
- No client-side cast bar at all anymore — this was an accepted, explicit tradeoff (a real cast bar
was a "nice to have," not the actual goal; a reliable, correct interrupt-on-move mechanic that
doesn't risk misfiring a real spell's effect was worth more once v1.7 proved unreliable).
- `SPELL_HEARTHSTONE_DONOR` (the 8690 constant), `PendingWaygateOpens`, `OpenWaygate`,
`spell_waygate_network_teleport`, and its `RegisterSpellScript` call are all deleted outright —
nothing from the v1.7 mechanism is kept dormant/toggleable, since there's no reason to preserve a
known-unreliable path.
- `data/sql/db-world/updates/2026_08_15_03.sql` deletes the `spell_script_names` row v1.7's
`2026_08_15_02.sql` had inserted, so a live/test DB that already applied v1.7's migration gets
cleaned up rather than left with a dead binding pointing at a script class that no longer exists
in the compiled binary.
### v1.9: animation + confirmed visual/sound IDs
Two polish items on top of v1.8's channel wait, plus finally picking the sound ID v1.4 shipped
disabled:
- **A looping animation on the player, not just the ground-effect visual.**
`Unit::SetEmoteState(EMOTE_STATE_SPELL_CHANNEL_OMNI)` — Blizzard's own generic spell-channel
stance, purely cosmetic like the visual kit — set alongside the `SendPlaySpellVisual` call in
`BeginWaygateChannel`, cleared unconditionally at the top of `FinishWaygateChannel` (every path:
success, moved-away, combat, can't-afford) so a cancelled/aborted wait can never leave a character
stuck posed.
- **`WaygateNetwork.ChannelVisualKitId` default picked and confirmed in-game: kit 267.** Traced
through this client's binary `Spell.dbc` -> `SpellVisual.dbc` -> `SpellVisualKit.dbc` (not
guessed) — it's the real sustained casting-loop visual (`SpellVisualEntry::CastingKit`) shared by
the entire Mage `Teleport: ` family (`SpellVisualID` 263), i.e. the actual glowing rune circle
spun under the caster's feet during a real Teleport cast. Checked alongside Hearthstone's own
casting-loop (kit 37) and the `Portal:` family's (kit 1486, paired with impact kit 729) as
alternatives — user picked 267 after previewing both via `.debug play visual ` in-game. Note the
actual command is `.debug play visual ` (nested under `.debug play`, alongside `sound`/`music`/
`cinematic`), not `.debug visual ` as an earlier draft of this doc's guidance mistakenly said.
- **`WaygateNetwork.UnlockSoundId` default picked: 1519, `TaxiNodeDiscovered`.** v1.4 shipped this
disabled (`= 0`) because the local dev world DB's `soundentries_dbc` SQL mirror table was
completely empty — same trimmed-table problem this module hit before with `Spell.dbc`/
`TaxiPath.dbc`. Fixed the same way: parsed this client's binary `SoundEntries.dbc` directly (12941
records, not guessed) and searched internal/file names for discovery/achievement/unlock-flavored
keywords. `TaxiNodeDiscovered` (`igNewTaxiNodeDiscovered.wav`) is Blizzard's own sound for
discovering a new flight point — the closest real in-game equivalent to unlocking a new
fast-travel destination, so it was the clear pick over more generic alternatives considered
alongside it (`ReputationLevelUp` id 8473, `LEVELUP`/`QUESTCOMPLETED` family).
### v1.10: faction gating
Came up mid-conversation while reviewing the first batch of candidate quest-hub destinations for
item 2 ("More locations") — Hillsbrad Foothills' two obvious town candidates, Southshore (Alliance)
and Tarren Mill (Horde), are each hostile territory to the other faction. Without a faction check, a
character could attune one by sneaking in once, then freely fast-travel back any time — landing them
in the middle of enemy guards with no warning, not a fun surprise.
- **New `faction` column** on `mod_waygate_network_locations`
(`ENUM('Alliance','Horde','Neutral')`, default `Neutral`) — mirrors the existing `continent`
column's pattern (a curated label set per-destination, not derived from anything else).
`data/sql/db-world/updates/2026_08_15_04.sql` adds it and tags the 8 existing capitals correctly
(Stormwind/Ironforge/Darnassus/Exodar `Alliance`, Undercity/Orgrimmar/Thunder Bluff/Silvermoon
`Horde`); Shattrath and Dalaran stay at the `Neutral` default since they're real sanctuary cities
open to both factions.
- **`WaygateFactionAllowed(player, dest)`** checks `dest.faction` against `Player::GetTeamId()`
(`TEAM_ALLIANCE`/`TEAM_HORDE`) — `Neutral` always passes. Applied in two places: filtered out of
the menu entirely in `GetSelectableDestinations` (so an opposing-faction destination just never
shows up, no special messaging needed, same as an unattuned one), and re-validated again in both
`BeginWaygateChannel` and `FinishWaygateChannel` — the same "don't trust the gossip action alone,
a client could replay a stale/forged packet" defense already applied to the attunement check there.
- **Attunement itself (`OnPlayerUpdateZone`) is deliberately left faction-agnostic** — a character
who somehow visits an enemy zone still gets it recorded, it just won't show up in their menu (or
be travelable) while on that faction. This is better than filtering attunement too, not just
simpler: if a character ever faction-changes (a real paid Blizzard-style service, out of scope for
this module itself but plausible on any server), their old visits to what's now their own
faction's territory become usable immediately, with no need to re-visit anything.
- **GM debug mode is the only override**, and needed no new code — `IsWaygateDebugFor` (GM security
+ `WaygateNetwork.Debug` config) already bypasses attunement entirely for listing/reviewing every
destination, and the debug branch in `OnGossipSelect` teleports directly without ever calling
`BeginWaygateChannel`/`FinishWaygateChannel`, so it was never in the faction check's path to begin
with. One unified "GM sees/travels everywhere" concept, not two separate permission bypasses.
- **Unblocks resuming item 2 below**, in particular resolving the Hillsbrad Foothills dilemma from
the (in-progress, not yet written to SQL) Eastern Kingdoms candidate list: both Southshore
(`Alliance`) and Tarren Mill (`Horde`) can now exist as separate destinations, each visible only to
its own faction, instead of having to pick just one.
### v1.11: Eastern Kingdoms quest-hub batch
The first real content pass on item 2 ("More locations") — the local dev world DB's `creature.zoneId`
column is empty/trimmed (same recurring problem this module keeps hitting with local SQL mirrors),
so real per-zone coordinates couldn't come from an obvious single query. Built instead from three
real, cross-checked sources rather than guessed:
- **Position: real innkeeper NPC spawns.** Blizzard already places one at essentially every hub town
(`creature_template.npcflag & 65536`, the innkeeper flag) — 22 across Eastern Kingdoms locally.
Reusing their exact spawn coordinates means every destination is a real, in-world-verified position,
not an eyeballed guess.
- **Zone: nearest-graveyard lookup.** `graveyard_zone`/`game_graveyard` (populated locally, unlike
`creature.zoneId`) tags real coordinates with a real zone id; matched each innkeeper to its nearest
graveyard on the same map, then resolved that zone id to a name via `AreaTable.dbc`. **Caught one
real mismatch this way**: Southshore/Tarren Mill's nearest graveyard resolved to "Alterac Mountains"
(zone 36), not "Hillsbrad Foothills" (zone 267) — a border-proximity artifact of the heuristic,
since the two zones sit right next to each other. Corrected manually to 267 based on the NPC
positions/names matching well-known real town locations; worth remembering this heuristic can
misfire right at zone borders, not just be trusted blindly.
- **Faction: `creature_template.faction` -> `FactionTemplate.dbc`'s `ourMask`.** Same method core's
own `AuctionHouseMgr.cpp` uses to classify an NPC's faction (`ourMask & FACTION_MASK_ALLIANCE` /
`FACTION_MASK_HORDE`, `src/server/shared/DataStores/DBCEnums.h`). This is what actually made the
faction tags on this batch real data rather than "seems right from general WoW knowledge" — e.g. it
independently confirmed Booty Bay (Stranglethorn Vale) and Light's Hope Chapel (Eastern
Plaguelands) both key to `Neutral`, matching their real in-lore status as ground open to both
factions, without needing to rely on memory for that.
- **Skipped as redundant with existing capitals:** Stormwind (in Elwynn Forest), Ironforge (in Dun
Morogh), and Undercity (in Tirisfal Glades) all had their own innkeeper match, but each landed
within a few hundred yards of the *already-existing* capital-city destination — not worth a second,
nearly-identical entry.
- **Two names flagged as inferred, not database-confirmed:** Revantusk Village (Hinterlands, Horde)
and Menethil Harbor (Wetlands, Alliance) — the zone/faction resolution is solid for both, but the
specific town *name* was inferred from general knowledge of what's in that zone rather than read
off an in-DB label, and Menethil Harbor's matched innkeeper was also the batch's largest
nearest-graveyard distance (481 yards, vs. under 400 for everything else). Worth a quick in-game
glance before trusting the label, same epistemic caveat as the unverified sound ID/portal model
already noted in "Known v1 limitations."
- **Excluded entirely: an "Innkeeper Adegwa" match that resolved to "Arathi Basin"** (zone 3358) —
that zone is the Arathi Basin *battlegrounds* map (529), not anywhere on the Eastern Kingdoms
continent (map 0), so a graveyard record with that `GhostZone` existing on map 0 is some kind of
BG-queue-staging data quirk, not a real open-world zone tag. Left out rather than guessed at.
- `data/sql/db-world/updates/2026_08_15_05.sql` adds all 17 rows (IDs 11-27) in one migration.
### v1.12: Kalimdor quest-hub batch
Same three-source method as v1.11, with one refinement that came out of noticing the graveyard
table's `Comment` field usually already spells out the real town name (e.g. "Mulgore, Bloodhoof
Village"), not just the zone: instead of resolving an innkeeper to its zone and then naming the hub
from memory, each innkeeper was matched against the *specific* named graveyard nearest it, and that
graveyard's own comment supplied the town name directly. Every name in this batch is DB-confirmed
this way — no flagged/inferred names like v1.11's Revantusk Village/Menethil Harbor.
- **18 destinations, IDs 28-45**, one per Kalimdor zone: Astranaar (Ashenvale), Auberdine
(Darkshore), Sar'theris Strand (Desolace), Razor Hill (Durotar), Brackenwall Village/Mudsprocket/
Theramore Isle (Dustwallow Marsh — genuinely has three real, distinct settlements, one per faction
plus a neutral one, so all three made the cut instead of the usual one-per-zone), Camp Mojache/
Feathermoon Stronghold (Feralas), Bloodhoof Village (Mulgore), Cenarion Hold (Silithus), Gadgetzan
(Tanaris), Dolanaar (Teldrassil), The Crossroads/Camp Taurajo/Ratchet (The Barrens — same
three-real-hubs situation as Dustwallow), Freewind Post (Thousand Needles), Everlook
(Winterspring).
- **Skipped, each for a real, checked reason, not left out arbitrarily:**
- Durotar's "Innkeeper Gryshka" and Mulgore's "Innkeeper Pala" both matched within ~70-90 yards of
the *already-existing* Orgrimmar/Thunder Bluff capital destinations; Teldrassil's "Innkeeper
Saelienne" matched ~130 yards from the existing Darnassus capital — all three redundant with a
capital already on the list, same as the Stormwind/Ironforge/Undercity skips in v1.11.
- **Stonetalon Mountains left out entirely.** Three innkeeper candidates, but none matched a named
graveyard within a confident distance — the nearest were 200-500 yards out, and even those
resolved to unnamed "GY" entries or path/subzone labels (e.g. "Webwinder Path"), not clear town
names. Rather than guess a town name from general knowledge (the exact trap this refinement was
built to avoid), the whole zone was left for a later, more manual pass.
- The Barrens' "Innkeeper Kaylisk" matched a graveyard labeled just "Kargathia" — not a confident
real-town name, and The Barrens already has two solid, unambiguous hubs from this batch
(Crossroads, Camp Taurajo), so a third uncertain one wasn't worth the risk.
- An "Alexston Chrome" NPC near Tanaris matched the Caverns of Time graveyard — a dungeon/instance
portal entrance, not a real settlement; skipped since Tanaris already has Gadgetzan.
- `data/sql/db-world/updates/2026_08_15_06.sql` adds all 18 rows in one migration.
### v1.13: Outland quest-hub batch
Same method as v1.11/v1.12. Outland's graveyard data turned out richer than the earlier continents'
in one specific way — several graveyard `Comment`s directly name real iconic hubs (`Area 52`,
`Thrallmar`, `Honor Hold`, `Zabra'jin`, `Cenarion Refuge`, `Wildhammer`, `Shadowmoon Village`, ...),
so most of this batch is as solid as v1.12's. Two things came up that hadn't in the first two
continents, though:
- **Three names inferred rather than DB-confirmed:** Garadar (Nagrand, Horde), Allerian Stronghold
and Stonebreaker Hold (both Terokkar Forest). For each, the nearest named graveyard only resolved
to a generic label (a lake name, a "Wilderness GY", etc.), not a settlement — the actual town name
came from recognizing the resolved zone/faction/coordinates as matching a well-known real location,
same epistemic tier as v1.11's Revantusk Village/Menethil Harbor. Worth an in-game glance before
trusting these three specifically.
- **A real contradiction between the batch's two verification signals, caught rather than papered
over.** Hellfire Peninsula's "Innkeeper Bazil Olof'tazun" resolved to `Horde` via
`creature_template.faction` -> `FactionTemplate.dbc`, but matched nearest to a graveyard
("Falcon Watch") known to be a real Alliance-owned outpost. Rather than trust one signal over the
other, left this NPC out of the batch entirely — a mismatch like this is exactly the kind of thing
the two-source cross-check exists to catch, and the right response to catching one is dropping the
entry, not guessing which source is wrong.
- **Multiple real, distinct settlements per zone kept, same as Dustwallow Marsh/The Barrens in
v1.12** where the data supported it: Hellfire Peninsula (Honor Hold + Thrallmar) and Zangarmarsh
(Cenarion Refuge, The Harborage, Zabra'jin) each got more than one entry, since each candidate was
a genuinely separate, real, faction-tagged location rather than a near-duplicate.
- **Skipped, each for a real reason:** several Eversong Woods innkeepers that all resolved within a
few hundred yards of the already-existing Silvermoon City capital; a second Blade's Edge Mountains
Alliance candidate (Toshley's Station) and a naming-uncertain neutral one (Evergrove); a second
Hellfire Alliance candidate (Temple of Telhamat) redundant with Honor Hold; two Terokkar Forest
NPCs that resolved to Shattrath City at a distance too large to trust as "at" the (already-covered)
capital; Isle of Quel'Danas entirely (its only candidate matched a generic "Staging Area" graveyard,
no confident settlement name, and it's a small late-added daily-quest island besides); Azuremyst
Isle's "Caregiver Breel" (matched a furbolg-camp name, not a real settlement — the zone's real hub,
Azure Watch, is already covered by a different innkeeper); and four NPCs that resolved to
non-standard zone labels ("The Veiled Sea", "Twisting Nether") — almost certainly boat-crew NPCs
for the Azuremyst/Bloodmyst naval transports, not real quest hubs.
- `data/sql/db-world/updates/2026_08_15_07.sql` adds all 16 rows (IDs 46-61) in one migration.
### v1.14: Northrend quest-hub batch
Same method as v1.11-v1.13, closing out full-continent coverage. Northrend's graveyard data was
about as rich as Outland's — most of this batch resolved to iconic, DB-confirmed real names
(Valiance Keep, Warsong Hold, Wyrmrest Temple, Valgarde, Vengeance Landing, Camp Nesingwary, ...).
- **Three names inferred rather than DB-confirmed:** Conquest Hold (Grizzly Hills, Horde), K3
(The Storm Peaks, Neutral), The Argent Stand (Zul'Drak, Neutral) — same tier as v1.11's/v1.13's
inferred names. K3 in particular had the largest nearest-graveyard distance in this batch (315
yards, vs. under 250 for everything else DB-confirmed), so it's the least confident of the three
— worth checking first if any of this batch turns out wrong in-game.
- **A second real red flag, different in kind from v1.13's faction contradiction.** Icecrown's most
promising candidate — a real Alliance/Horde pair (Caris Sunlance / Jarin Dawnglow) standing at what
looked like the Argent Tournament Grounds — resolved via `AreaTable.dbc` to zone id 4722 ("Trial of
the Crusader"), and that zone id's own `mapid` field is 649, a completely different map than 571
(Northrend), which is what the creatures' own `map` column actually says they're standing on. Real
Blizzard data can apparently tag an outdoor sub-area with a zone id that nominally "belongs" to a
different map (probably some interaction with how that tournament area straddles the instance
complex) — whatever the actual reason, it made it genuinely unclear whether a real player standing
there would report `zoneId = 4722` for attunement purposes, or something else entirely. Rather than
ship a destination that might silently never attune (the attunement check in `OnPlayerUpdateZone`
just wouldn't match, with no error to notice), left the pair out. Icecrown itself has no other
candidate either (see skip list below), so the whole zone is left for a manual pass later.
- **Skipped, each for a real, checked reason:**
- **All of Crystalsong Forest** — every innkeeper there matched within roughly 50-180 yards of the
existing Dalaran capital destination, since Dalaran floats directly above Crystalsong Forest.
Fully redundant, not worth a near-duplicate entry the way a couple of individual NPCs were
skipped in earlier batches.
- Howling Fjord (5 more candidates beyond the 2 picked), Storm Peaks (5 more beyond the 1 picked),
Grizzly Hills (2 more beyond the 2 picked), Zul'Drak (1 more beyond the 1 picked), and Borean
Tundra (1 more beyond the 3 picked) all had additional innkeepers that only matched
generic/unnamed graveyards ("North GY", "Foot Steppes GY", "Southeastern GY", ...) at
moderate-to-large distances — none confident enough to name, especially once a real
DB-confirmed pick already covered the same zone/faction.
- `data/sql/db-world/updates/2026_08_15_08.sql` adds all 13 rows (IDs 62-74) in one migration.
- **This completes item 2's four-continent rollout** (v1.11 Eastern Kingdoms, v1.12 Kalimdor, v1.13
Outland, v1.14 Northrend) — see "Next steps" below for what's still open (a handful of
naming-uncertain zones flagged for a manual follow-up pass: Stonetalon Mountains, Isle of
Quel'Danas, Nagrand's Alliance side, Icecrown).
### v1.15: The Ley Wardens
Item 7 ("Short intro quest line + lore"), the last item on the original "Next steps" list. Planned
via Claude Code's plan-mode workflow — an Explore pass over this codebase's quest system (schema,
hooks, real API, existing precedent) followed by a Plan pass that turned the agreed narrative concept
into a concrete, DB-verified implementation, both reviewed and finalized before any SQL/C++ was
written. See README.md's "Intro quest chain" section for the player-facing summary; this section is
the design reasoning and what got decided along the way.
- **The gate is a real, deliberate behavior change, not a bug.** `.waygate` has been usable from
level 1 with zero prerequisite since v1. As of this version, it does nothing at all until the
chain's finale (quest 912005) is complete — confirmed explicitly with the user, not assumed.
`WaygateNetwork.RequireIntroQuest` (default on) lets an admin disable the gate entirely without
touching quest data; GM debug mode remains the only other bypass, reusing the existing
`IsWaygateDebugFor` check rather than inventing a second permission concept. Passive attunement
(`OnPlayerUpdateZone`) is completely untouched — a character can keep discovering destinations
before finishing the chain, so completing it doesn't leave them staring at an empty menu.
- **Zero new `CreatureScript` code anywhere in the chain.** Verified against real precedent
(`src/server/scripts/World/npc_taxi.cpp:96`) that `Player::GetQuestRewardStatus(uint32) const`
(`src/server/game/Entities/Player/Player.h:1491`) is a stable, correct read for "has this player
finished quest X," independent of session/relog. A plain accept/turn-in questgiver needs nothing
beyond `creature_template.npcflag` including `UNIT_NPC_FLAG_QUESTGIVER` (`= 2`) and
`creature_queststarter`/`creature_questender` rows — core's default gossip/quest UI does the rest.
The entire gate is one guard clause in `HandleWaygateCommand`.
- **Structure: accept-from-one, turn-in-to-the-next, not out-and-back.** Aemos (Shattrath) → Echo of
the Ley Wardens (Light's Hope Chapel, EK) → Windcaller Tyese (Cenarion Hold, Kalimdor) → Nazrek the
Bent (Lower City, Shattrath) → The First Warden (Wyrmrest Temple, Northrend) → back to Aemos for
the return leg and the finale. Deliberately passes through Shattrath three times (start, the
Outland leg, the return/finale) — not backtracking for its own sake, but because Shattrath is
genuinely "the crossroads of all worlds" in WotLK-era lore, so both Aemos and the Outland contact
calling it home reinforces the story rather than undercutting it.
- **NPC placement reused already-verified ground wherever possible.** Three of the five new NPCs
(Eastern Kingdoms, Kalimdor, Northrend) sit at the *exact* coordinates of an existing Waygate
destination — zero new verification needed, since that ground was already confirmed safe by the
v1.11-v1.14 batches. Aemos and Nazrek the Bent (Outland) are placed a few yards from real,
DB-queried landmark NPCs (A'dal/Khadgar at Shattrath's Terrace of Light; Voren'thal the Seer at the
Lower City, matched to his own elevation via nearby NPCs also at z~80, not the tradesmen platform
below at z~67) on the same already-Neutral ground, rather than introducing an unverified new spot.
All five reuse faction 35 — already documented "hostile to nobody" by `npc_waygate_portal`, no new
faction to check.
- **Display IDs are real, checked creature models, not guessed.** Queried the local `acore_world` DB
for fitting, non-hostile displays rather than picking numbers blind: Aemos got `15346`
("[UNUSED] Obsidian Watcher" — a real shipped display never used in live content, so no risk of
clashing with an established character's identity); the Eastern Kingdoms and Northrend contacts
deliberately share `16169` ("Spectral Apparition") — not a shortcut, since the chain's own turn
(quest 4's completion text) reveals both are the same kind of thing: an echo Aemos cast off long
ago to keep part of itself from being forgotten. Windcaller Tyese got `4249` ("Cenarion Druid"),
Nazrek the Bent got `18027` ("Broken Refugee" — an ordinary elderly/civilian model, not a
corpse/skeleton/hostile one that shares the "Broken" name).
- **ID ranges, checked against real current max IDs before reserving anything** (`SELECT MAX(ID) FROM
quest_template` = `26034` locally; nothing above `901001` existed anywhere except this module's own
`911000`/`911001`): creature entries `911002-911006` (continuing this module's existing flat
`911xxx` space), quest IDs `912000-912099` (a new block — quests are a semantically different kind
of ID than creatures/text, kept separate rather than sharing the `911xxx` space, with room left for
a future second chain without renumbering). No separate ID range needed for greeting text —
`quest_greeting` turned out to be keyed directly by the NPC's own creature entry (confirmed against
`data/sql/base/db_world/quest_greeting.sql`), not a separate reservation, simplifying the original
plan slightly.
- **Level-1, no-combat travel safety — decided to rely on the existing design, no new mechanic.**
Flagged explicitly as an open question during planning rather than assumed either way. Considered
and rejected a temporary protective buff (would need its own DBC-verification research this module
hasn't done, for a problem that may not be real) in favor of: three of five legs already sit on
real Neutral hub ground reachable via non-combat transport, the Outland leg adds zero travel risk
beyond reaching Aemos in the first place (same city), and a level-1 death has essentially no cost
in this game (no meaningful durability/gear to protect, a short corpse-run at worst). Keeps the
module's "additive, not a balance change" pillar intact. If the Northrend leg's overland stretch to
Wyrmrest Temple turns out to be a real problem in testing, the noted cheap fix is relocating that
leg's turn-in to the existing Valgarde/Vengeance Landing destinations (IDs 70/71 — the literal
boat/zeppelin drop-off points) instead, without needing to rediscover the idea from scratch.
- `data/sql/db-world/updates/2026_08_15_09.sql` (the 5 NPCs) and `_10.sql` (the quest content: 6
`quest_template` rows, `quest_template_addon` chaining, `quest_offer_reward`/`quest_request_items`
lore text, `creature_queststarter`/`creature_questender` wiring) add this version's content.
### v1.16: Waygate Stones
Item 8 ("Interactable attunement objects"). Same plan-mode workflow as v1.15: an Explore pass over
this codebase's `GameObjectScript` system (a first for this module — everything before this used
`CreatureScript`) followed by a design synthesis, reviewed with the user via `AskUserQuestion` on
three open forks before writing any SQL/C++.
- **Three decisions confirmed with the user before implementation, not assumed:** use a real Standing
Stone-family object (which one changed after shipping — see below); keep a zone-entry hint rather
than removing all signal when a stone hasn't been found yet; hold back the cleanup migration for
now rather than writing it, since the user is still actively testing on their own characters and
wiping that data would undo the testing they were about to do.
- **`GameObjectScript::OnGossipHello(Player*, GameObject*)`** (`GameObjectScript.h:34`) is the real
"player used this object" hook — fires from `GameObject::Use()` before any type-specific default
behavior, for *any* GameObject type, not just interactive ones; returning `true` suppresses further
default behavior with no gossip window needing to actually open. No precedent anywhere in this repo
(core or any module) for a `GameObjectScript` writing to `CharacterDatabase` — reused this module's
own `OnPlayerUpdateZone` select-then-insert idiom rather than inventing something new.
`rotation0-3` on a spawn row don't need manual quaternion math: leaving all four at `0` makes
`GameObject::SetWorldRotation` fall back to computing an upright rotation purely from `orientation`
(`GameObject.cpp:2261-2270`), the same as a Creature.
- **Object choice, round 1: "Caverns of Time Standing Stone" (`gameobject_template` entry `19501`,
`displayId 9501`)** over the also-real "Meeting Stone" (entry `19532`, `displayId 9532`, the
classic dungeon-entrance monolith originally referenced as "small dungeon portal stone"). Standing
Stone won on lore fit — it's already named exactly "Standing Stone," matching the chain's own
vocabulary precisely — and on carrying no baggage from an existing, differently-purposed use, unlike
Meeting Stone's strong existing LFG/summon association in every player's mental model. Both are
`type = 5` (`GAMEOBJECT_TYPE_GENERIC`, correcting an earlier in-conversation "GOOBER" guess — this
doesn't affect the approach, `OnGossipHello` fires for any type regardless).
- **Object choice, round 2 (after shipping): displayId 9501 turned out invalid on this server.** The
user tried to spot-check a stone in-game and couldn't see it; asked whether the Z-height matched
the reference NPC exactly (it does — every stone reused the destination's coordinates verbatim, a
separate, still-open placement issue noted below) and to try `.gobject add` on both our clone
(`913013`) and the real `19501` for a side-by-side comparison. **Both failed to spawn, with an
identical worldserver log line**: `"Gameobject (Entry ... GoType: 5) have invalid displayId (9501),
not spawned."` — for the real, unmodified Blizzard entry too, which conclusively rules out a
scripting/template bug on our side. Root cause: this server's actual loaded
`GameObjectDisplayInfo.dbc` is a reduced client build — parsed directly (same discipline as every
other DBC check this module has done), it only contains ~3790 populated display records (a normal
3.3.5a client has 9000+), with real gaps; `9501` isn't one of them. **This means the
`gameobject_template` SQL data dump this repo ships with references displays this server literally
cannot render** — a new category of "verify, don't trust the SQL dump" lesson, distinct from every
earlier DBC-trimming issue this module hit (those were about *local dev DB* mirrors being
incomplete; this is about the *server's own loaded client data* being a reduced build, a different
and more fundamental kind of gap).
- **Re-picked by filtering real `gameobject_template` candidates against the actual, server-loaded
display ID set** (extracted directly from the real `GameObjectDisplayInfo.dbc`), not just the SQL
dump — a search method this module hadn't needed before. Landed on **"Nightelf Stone Rune"**
(entry `182081`, `displayId 236`) — `type = 5`, every `Data0-23` field zero, no `AIName`, a
genuinely inert object like the original pick was meant to be.
- **User asked about Titan-flavored alternatives first, given the Waygate/ancient-network lore** —
investigated and rejected three real candidates (`Titan Relic`, `The Discs of Norgannon`,
`Ulduar Teleporter`) after finding each one carries real hidden behavior a plain `OnGossipHello`
override isn't guaranteed to fully suppress: `Titan Relic` has a live cast-bar caption
("Activating") and multiple non-zero spell/quest-linked `Data` fields; `The Discs of Norgannon`
is a genuine, already-scripted Uldaman lore artifact with `SmartGameObjectAI` and a lock/quest
link; `Ulduar Teleporter` looked perfect by name alone but is actively `SmartGameObjectAI`-driven
and every real spawn sits *inside the Ulduar raid instance itself* (map 603), tied to actual
encounter mechanics. None were "all zero, no AI" the way Nightelf Stone Rune is — the Titan
angle, if it stays appealing, was suggested to live in the *lore* instead (the network's stones
as something the Ley Wardens merely rediscovered, built by something even older) rather than in
the object choice.
- **Object choice, round 3: Nightelf Stone Rune rendered fine but couldn't be interacted with at
all.** The user reported the stone was now visible (round 2's fix worked) but right-clicking it did
nothing. Root cause found by comparing `type` against every real "click this object, run custom
logic via `OnGossipHello`" `GameObjectScript` already in this codebase
(`go_southfury_moonstone`, `go_tele_to_dalaran_crystal`, and others in `go_scripts.cpp`) — every
single one uses `type = 10` (`GAMEOBJECT_TYPE_GOOBER`), confirmed against the real
`GameobjectTypes` enum (`src/server/shared/SharedDefines.h:1562`). Both of our picks so far
(Standing Stone *and* Nightelf Stone Rune) were `type = 5` (`GAMEOBJECT_TYPE_GENERIC`) — real,
existing objects, correctly identified from the SQL data, but the wrong *category* of object
entirely: `GENERIC` is plain, non-interactive scenery, and `GameObject::Use()`
(`GameObject.cpp:1463`) only gates on `GO_FLAG_NOT_SELECTABLE` before calling `OnGossipHello` — so
the server-side code was never the problem, but the client most likely never even offers a "use"
prompt for a `GENERIC` object, meaning our script had no chance to run at all regardless of how
correctly it was wired. **A real lesson distinct from round 2's**: matching a real object's name to
the lore isn't enough — its `type` has to match a real, proven-interactive category too, verified
against actual working precedent in this codebase, not just plausibility from the name/Data fields.
Re-picked, filtered this time for `type = 10` *and* a valid displayId: **"Ancient Stone Marker"**
(entry `188469`, `displayId 7789`) — no `AIName`/`ScriptName` of its own, one harmless non-zero
field (`Data3` = `autoCloseTime` = 3000ms, an "activated" visual reset our `OnGossipHello`
override suppresses by returning `true` before it would ever run).
- **One dedicated `gameobject_template` entry per destination (`913001-913074`,
`entry = 913000 + destination id`), not one shared template.** The original sketch considered a
single shared template with the specific stone identified by its `gameobject.guid` — dropped after
finding real `gameobject.guid` values in the local DB already run into the millions, too real a
collision risk for a low, self-chosen deterministic range. Dedicated per-destination entries sidestep
the problem entirely (this module already fully owns that ID space) and mirror the exact pattern
already proven three times over for NPCs (`901000`, `911001`, `911002-911006`) — the C++ side
resolves "which destination is this" from `go->GetEntry() - WAYGATE_STONE_ENTRY_BASE`, no runtime
position-matching or guid bookkeeping needed.
- **`OnPlayerUpdateZone` rework**: kept the zone-match lookup, dropped everything downstream of it
(the DB insert, sound, chat message, first-attunement tip — extracted into a new shared helper,
`AttuneWaygateDestination`, called only from `go_waygate_standing_stone::OnGossipHello` now) and
replaced it with a one-line hint. Accepted, deliberately unaddressed trade-off: since attunement no
longer marks "this already happened" at zone-entry time, the hint repeats every time an un-attuned
character re-enters the zone, not just once. No session-scoped throttling added — a real design
question of its own (no clean existing storage slot for "hinted this session"), not worth solving
speculatively for a one-line, low-annoyance message.
- **Still deferred, exactly as planned**: the "clear existing attunements/quest-chain completion for
real players" cleanup migration. Real, verified limitation on doing this cleanly, players-only, from
the plan-mode research (unchanged, still applies whenever this actually gets written): `IsPlayerbot()`
(`GET_PLAYERBOT_AI(player) != nullptr`) is a live, in-memory-only check via `mod-playerbots`' own
`_playerbotsMgrMap` — nothing in the schema persists a guid-level "this character is a bot" marker.
**Random-pool bots** *are* identifiable purely in SQL (dedicated accounts named with a configurable
prefix, default `rndbot` — `mod-playerbots` itself already does bulk `characters` deletes this exact
way in `RandomPlayerbotFactory.cpp`, real precedent to copy). **Alt bots** (a real player's own
characters, toggled to bot-controlled) are **not** distinguishable from a normal character at the DB
level at all — they share the real player's own account, nothing marks which specific character is
currently bot-piloted. A DB-only cleanup filtered on account username will still catch these rows if
their quest IDs/waygate discoveries match. Accepted as a known gap rather than something to solve
with SQL cleverness that doesn't actually exist — an alt bot's attunement/quest state resetting
alongside its owner's other characters isn't really wrong, just not selectively excluded.
- **Still open: exact position overlap with the reference NPC, confirmed but not yet fixed.**
Every one of the 74 stones reused its destination's coordinates verbatim — and those coordinates
were themselves originally sourced by copying a real landmark NPC's exact position (an innkeeper,
Voren'thal, etc., back in v1.11-v1.14's curation work). So every stone spawns at the identical
x/y/z as an existing NPC, almost certainly clipped inside/underneath that NPC's model — likely the
real reason the user couldn't spot the Darkshire one even before the displayId bug was found. Fixed
in v1.17, see below.
- `data/sql/db-world/updates/2026_08_15_11.sql` adds all 148 rows (74 `gameobject_template` + 74
`gameobject` spawns) in one migration, generated directly from `mod_waygate_network_locations`'s
own data rather than retyped by hand. Display ID corrected in place after the round-2 finding above
(`sed`-replaced across all 74 `gameobject_template` rows: `9501` -> `236`), not left as a separate
follow-up migration, since nothing had been applied to a live server yet.
### v1.17: cosmetic polish
Requested once v1.15 and v1.16 were both confirmed fully working in-game — two follow-ups, one more
substantial than "cosmetic" really implies.
- **Position offset for all 74 Waygate Stones and 3 of the 5 Ley Wardens NPCs.** Every stone reused
its destination's coordinates verbatim, and those coordinates were themselves sourced by copying a
real landmark NPC's exact position back in the v1.11-v1.14 continent batches — so every stone was
spawning clipped inside/underneath an existing NPC. Worse, three Ley Wardens NPCs (Echo of the Ley
Wardens, Windcaller Tyese, The First Warden) share those same exact destination coordinates too, so
they were sitting inside both the landmark NPC *and* their own corresponding stone simultaneously.
Fix: nudge each stone 3 yards along its destination's own saved `orientation`
(`new_x = x + 3*cos(o)`, `new_y = y + 3*sin(o)`), and the three affected quest NPCs 3 yards the
*opposite* direction (`new_x = x - 3*cos(o)`, `new_y = y - 3*sin(o)`) — the original landmark NPC
stays exactly where it always was (already-tested ground), the stone and the quest NPC end up 6
yards apart from each other, 3 yards to either side of it. Aemos and Nazrek the Bent were already
manually offset ~9-11 yards from their own landmark references back in v1.15 and needed no change.
**Explicitly a heuristic, not individually terrain-verified** (no vmap/LOS data available to this
tooling) — small and low-risk, but not a guarantee every one of the 74 lands somewhere sensible;
worth a real in-game pass rather than assuming it's perfect.
- **One-shot cosmetic flash on successful attunement.** `AttuneWaygateDestination` now also calls
`player->SendPlaySpellVisual(...)` (`WaygateNetwork.AttuneVisualKitId`, default kit `3394`) on top
of the existing sound and chat message. Originally sketched as playing *from the stone itself* (the
stone visibly lighting up, not the player) — dropped after finding `GameObject` has no
`SendPlaySpellVisual` of its own (it's a `Unit`-only method; `GameObject` does have
`SendCustomAnim(uint32 anim)`, but with no known-valid animation ID for this specific model and no
way to verify one without live testing, playing on the player instead was the safer, already-proven
choice). Kit `3394` isn't a new pick — it's the same real Teleport-family visual's `ImpactKit`
already traced via binary DBC parse back when kit `267` (that family's `CastingKit`) was chosen for
the travel channel, reused here to keep the two moments visually related rather than introducing an
unrelated new effect.
- `data/sql/db-world/updates/2026_08_15_12.sql` adds both fixes in one migration (74 `gameobject`
position updates + 3 `creature` position updates) — plain `UPDATE ... WHERE id = X` per row rather
than the module's usual `DELETE`+`INSERT` pattern, since only two columns needed to change and
every other column on these rows was already correct.
### v1.18: gating the stones
Requested as a follow-up to v1.16: with attunement now happening at the stones instead of on
zone-entry, a character could attune destinations before ever finishing the Ley Wardens chain — the
chain gates *travel* (`.waygate`) but had nothing stopping the stones themselves from working early.
Asked for either full invisibility or a hard interaction block, with the option for evocative
gossip-style flavor text over a plain chat line if the block route was taken.
- **Investigated true invisibility first, found a real dead end worth recording.** AzerothCore's
`ConditionMgr` has `CONDITION_SOURCE_TYPE_OBJECT_VISIBILITY` (`ConditionMgr.h:156`) — by name and
by its load-time validation code (`ConditionMgr.cpp:1924-1978`, which explicitly checks
`SourceGroup` for "0 (creature) or 1 (gameobject)" and validates `SourceEntry`/`SourceId` against
real creature/gameobject data), this looks purpose-built for exactly "hide this GameObject from
players who don't meet a condition." Traced it across the entire `src/` tree looking for where a
condition of this type actually gets *fetched and applied* during a real visibility check (the
pattern every other condition source type in this codebase follows, e.g. `Creature.cpp:2038` for
`CONDITION_SOURCE_TYPE_CREATURE_RESPAWN`) — found **zero** call sites anywhere outside
`ConditionMgr.h`/`.cpp` themselves. The condition type is defined and its SQL rows get validated at
load time, but nothing in this codebase ever consults it to actually hide anything. Genuinely dead,
unwired infrastructure — not a bug in this module, but a real gap in the engine fork worth
remembering before reaching for it again on anything else.
- The realistic alternative for true invisibility, WotLK-era phasing (`phaseMask`), was considered
and rejected too — it would mean toggling a player's *global* phase mask just for this, risking
interference with any other phased content on the server. A bigger, riskier lever than this
feature warrants.
- **Landed on a hard interaction gate instead** — same pattern as the existing `.waygate` gate,
applied inside `go_waygate_standing_stone::OnGossipHello` before the attunement call:
`WaygateNetwork.RequireIntroQuest` (already existed) plus `!IsWaygateDebugFor(player)` plus
`!player->GetQuestRewardStatus(QUEST_WAYGATE_REACTIVATION)`. The stone stays visible and clickable,
it just doesn't attune anything and shows flavor text instead of the normal attunement
sound/message/flash.
- **Flavor text over a plain chat line, per the ask.** Opens a real gossip popup
(`ClearGossipMenuFor` + `SendGossipMenuFor`, `npc_text` `911012`, `WAYGATE_STONE_LOCKED_GOSSIP_TEXT`
in the header) rather than `ChatHandler::PSendSysMessage` — the same mechanism this module has used
for every other gossip window, just with no `AddGossipItemFor` calls, so it shows only the
descriptive text with an implicit close. Text deliberately doesn't name Aemos directly (the
`.waygate` command's own dormant-network message already does that) — a stone is often a
character's *first* hint that anything's going on, before they've ever typed the command, so it
points toward Shattrath City "in a general sense" (per the ask) rather than spelling out exactly
who to find.
- `data/sql/db-world/updates/2026_08_15_13.sql` adds the new `npc_text` row.
### v1.20: The Ley Wardens redesign
Requested as a substantial follow-up once v1.15-v1.19 were all confirmed working: confine the chain
entirely to Eastern Kingdoms and Kalimdor (no Outland/Northrend), unlock it earlier (level ~20
instead of 80), start it somewhere with both a real Alliance and Horde town nearby, and add a real
combat leg — a Titan-built guardian that wakes to stop the player from surfacing the old ley magic.
Reached through several rounds of "let's speculate" before landing on a shape, then a full plan-mode
pass (given the scale: relocating/retiring shipped NPCs, adding a new hostile creature, rewriting
every quest, changing the quest count) before touching any SQL/C++.
- **Geography chosen to mirror itself.** Hillsbrad Foothills (Southshore/Tarren Mill) for Aemos —
real contested no-man's-land between an Alliance and a Horde town, exactly the level-20 "sneaking
between two towns that don't like each other" flavor asked for. Ashenvale (Astranaar, already an
existing Waygate destination) as the natural second leg, mirroring Hillsbrad's dynamic. Badlands
(Uldaman) for the "requires a level-30-zone's worth of sneakiness" leg *and* the combat encounter,
folded into a single leg rather than added as a separate quest (explicitly confirmed: "fold it into
the reveal leg, kill-credit is fine"). Stonetalon Mountains (Windshear Crag) for The First Warden,
preserving the original "First Warden is Aemos's own memory" twist just relocated.
- **The combat leg pays off an earlier suggestion.** When the original Waygate Stone object hunt (see
"v1.16: Waygate Stones") ruled out every Titan-themed GameObject candidate as too complex/risky to
reuse, the write-up at the time suggested the Titan angle "could live in the lore instead... rather
than in the object choice." This redesign's guardian is that payoff — "The Ironbound Sentinel," a
Titan-built earthen construct, ties the "network is older than the Ley Wardens" reveal to something
even older still trying to keep it quiet.
- **Only 3 intermediate NPC nodes now, not 4 — the quest count falls out of that, not the other way
around.** `Aemos → Tyese → Uldaman contact → First Warden → Aemos [return] → Aemos [finale]` is 4
transitions plus one same-place ritual = 5 quests, not 6. Rather than force an artificial 6th quest
to hit a round number, the now-unused finale quest ID (`912005`) is retired outright and
`QUEST_WAYGATE_REACTIVATION` in `mod_waygate_network.h` repoints to the new finale, `912004`.
- **NPC roster: reuse, relocate, repurpose, retire — all four, deliberately different treatments.**
Aemos/Tyese/The First Warden keep their name, model, and role, only relocating position + quest
text. "Echo of the Ley Wardens" (`911003`) is *repurposed*, not retired — its "spectral echo"
concept doesn't fit revealing something older than the Ley Wardens themselves (an echo of the order
wouldn't know that), so the entry becomes Borin Ironquill, an independent Explorers' League
researcher, reusing the real "Chief Archaeologist Greywhisker" display (`1665`) from a real NPC
that spawns nowhere near Uldaman, avoiding any local visual clash with the actual Explorers' League
roster standing a few yards away. "Nazrek the Bent" (`911005`, the old Outland contact) is retired
entirely with no replacement — the new geography has no role for a fourth intermediate node.
- **Guardian stats modeled on real data, not invented.** "The Ironbound Sentinel" (`911007`) reuses
Mor'Ladim's (`creature_template` entry `522`, Duskwood's real solo-able open-world elite) proven
tuning verbatim — `minlevel=maxlevel=30`, `rank=1`, `unit_class=1`, `HealthModifier=3`,
`DamageModifier=2.4` — since `creature_template` only stores multipliers against
`creature_classlevelstats`, not flat numbers, and no stone/earth-flavored solo overworld elite
exists in this level range to copy instead (the only stone/earth-named elites/rares found,
Razorfen Kraul/Downs mobs, are dungeon trash — group content, not a valid reference, flagged
honestly rather than misrepresented). `faction=21` (Mor'Ladim's own) reused too, after specifically
checking it wasn't undead-locked: its only other users in this DB are undead mobs, but faction here
governs hostility, not flavor, and Mor'Ladim himself isn't a skeleton either. Display `6026` reused
from the real "Earthen Guardian" creature (`creature_template` entry `7076`), confirmed present in
this server's actual `CreatureDisplayInfo.dbc`; that real creature only ever spawns inside the
Uldaman *instance* (`map 70`), never in the open world, so there's no collision risk reusing its
display on a new open-world-only entry. Real Uldaman boss names (Archaedas, Ironaya) deliberately
**not** reused for the new guardian's own name, to avoid confusion with actual dungeon content.
- **Kill-credit objective needed zero new C++.** `quest_template.RequiredNpcOrGo1` — positive value =
creature kill credit, negative = gameobject use credit (`QuestDef.h:305`, enforced at runtime in
`Player::KilledMonsterCredit`, `PlayerQuest.cpp:1986-2023`) — confirmed against real source before
relying on it. Quest `912001` sets `RequiredNpcOrGo1 = 911007`, `RequiredNpcOrGoCount1 = 1`.
- **Cleanup, not just addition** — explicitly asked for by the user going in ("do what you feel is
necessary, especially since there will be cleanup that needs to be done too"): quest `912005` and
Nazrek the Bent's `creature_template`/`creature_template_model`/`creature`/`quest_greeting` rows are
all deleted outright rather than left orphaned. The locked-stone flavor text (`npc_text` `911012`,
see "v1.18") and the `.waygate` dormant-network hint message both updated to point at the Hillsbrad
Foothills instead of Shattrath City, since both named Aemos's old location.
- All new content lives in `data/sql/db-world/updates/2026_08_16_01.sql` through `_04.sql` (this
module ships its own committed migrations rather than editing already-shipped ones in place, unlike
the in-place edits during v1.16's still-unshipped iteration).
- **Partially confirmed in-game (2026-08-19/20):** the early legs (Aemos → Tyese, quest accept/
turn-in flow) work correctly, after one real position bug (see "v1.22" below). The Uldaman combat
leg, Stonetalon leg, and full chain end-to-end have not been walked yet.
### v1.21: hint spam fix
First real in-game feedback on v1.16's zone-entry hint: `"You sense a waygate stone somewhere
nearby..."` firing several times in a row at times. That hint's unthrottled repeat behavior was
already a known, explicitly-accepted trade-off from v1.16 ("worth revisiting only if it turns out
actually bothersome in practice") — this is that revisit.
- **Root cause wasn't the hook re-firing for one zone entry — it was a single taxi flight crossing
several different un-attuned destination zones back to back.** `OnPlayerUpdateZone` fires once per
real zone change, correctly, but a flight path can cross multiple Waygate destination zones in
quick succession, each producing its own (individually correct) hint — reading as "spam" from the
player's seat even though each message was for a genuinely different zone. Confirmed by the user's
own second observation ("maybe we shouldn't detect stones while on a flightpath"), which turned out
to be the actual fix, not a separate ask.
- **Fix 1 — skip entirely while on a taxi.** `player->IsInFlight()` (`Unit::IsInFlight()`,
`UNIT_STATE_IN_FLIGHT` — confirmed set specifically by `FlightPathMovementGenerator::DoReset`, i.e.
real taxi flight, not just "airborne on a flying mount") added as an early-out alongside the
existing `WaygateNetwork.Enable`/`IsPlayerbot` guard. This alone accounts for the reported "3 in a
row" pattern.
- **Fix 2 — dedupe against the last destination actually hinted, as defense in depth.** A small
`WaygateHintState` struct (`lastHintedDestinationId`) stored on `Player::CustomData` — the standard
AzerothCore idiom for a module to attach per-player transient state without touching core headers
(`DataMap`, `src/server/game/DataStores/../../shared/DataMap.h`) — keyed `"WaygateNetworkHint"`.
Session-scoped only, not persisted to the DB, since it only needs to survive until the next actual
zone change. Covers the other real (if rarer) spam source v1.16's write-up flagged but left
unsolved: the hook firing more than once for a single real zone entry, or walking back and forth
across a zone line on foot. Deliberately still repeats across *different* un-attuned zones visited
on foot — that's the intended nudge, not a bug.
- No config toggle added — both guards are strictly quality-of-life fixes to the existing hint, not a
behavior change worth making optional.
### v1.22: first in-game walkthrough findings
The first real walkthrough of v1.20's relocated Ley Wardens NPCs, done by the user directly rather
than through a scripted test pass. Two unrelated findings, both worth recording since they generalize
beyond this one bug fix.
**Finding 1 — Aemos spawned but invisible, buried in terrain.** Reported as "the exclamation mark is
on the map but can't find him" — a useful diagnostic in itself: the quest "!" showing confirmed the
creature genuinely was spawned and in range (the client had received a real object update for a
nearby questgiver), which narrowed the problem to "wrong height," not "didn't spawn" or "wrong
displayId" (the class of bug this module already hit once before, in v1.16's Waygate Stones saga).
The v1.20 migration had estimated Aemos's `z` (53.4) from real spawns 15-19 yards away in the
Hillsbrad no-man's-land -- but that reference cluster itself spans z=47-55, a real sign of uneven
terrain in this specific micro-area, and the actual ground turned out to be ~10.7, a ~43-yard drop
(likely a cliff or ravine edge nearby). Fixed with the real GM tools for exactly this: `.npc near` to
confirm the creature was loaded and selectable despite being invisible, then `.npc move` (which
updates both the live creature and the world DB directly) to snap it to the user's own -- verified,
on-screen -- position. `data/sql/db-world/updates/2026_08_19_01.sql` captures the corrected
coordinates in the module's tracked migration history, since a live GM command's DB write doesn't
otherwise show up in git. **Generalizes to the other three relocated NPCs** (Tyese, Borin Ironquill,
The First Warden) -- their positions used the same nearby-spawn-interpolation heuristic and haven't
each been individually eyeballed yet, so the same bug could recur at any of them.
**Finding 2 — stale client-side quest text survives a server restart, and isn't a server bug.** After
turning in the first quest, the user's next quest showed as "Echoes in Stone" pointing to Silithus --
verbatim the *original* v1.15 title/destination for that same quest ID, even though the live DB (the
exact one this module writes to) had long since had that ID's row replaced with the new
"Roots Older Than the Circle"/Badlands content, and the worldserver had been restarted since the
redesign shipped (ruling out "the server just hasn't reloaded its in-memory quest cache," the first,
more mundane hypothesis). The real cause: the WotLK 3.3.5a client caches quest text to local disk (a
`WDB` folder in the client install) keyed by quest ID, and had already cached this ID's *old* content
from earlier testing of the original chain, before the redesign -- reusing a quest ID with entirely
different content is exactly the trigger for this. Fixed by deleting the client's `WDB` folder and
relogging, forcing a fresh fetch. **Worth remembering for any future quest content edit in this
module** (or any other) that touches a quest ID a tester has already seen -- this isn't specific to
the Ley Wardens chain, and no server-side fix exists for it.
### v1.23: manual stone repositioning
Closes out "Next steps" item 9, the last item on the pre-1.0 punch list. v1.17's 3-yard offset (see
"v1.17: cosmetic polish") got every stone out from directly inside its reference NPC, but it was a
blind heuristic with no real terrain data behind it -- explicitly flagged at the time as something
only a real walkthrough could fix properly, the same lesson v1.22 relearned the hard way with Aemos.
- **Process**: the user walked to each of the 74 stones in-game, used `.gobject near` to identify
which stone they were looking at (matching its `name`, `"Waygate Stone: "`, or its entry
`913000 + destination id` directly) and `.gps` to get real coordinates for a better spot, then fed
the results back in batches of ~14-20 rather than one at a time, keeping the round-trip count
manageable for 74 individual data points.
- **Applied as plain `UPDATE`s**, matching the exact pattern already established in v1.17's offset
migration -- only `position_x`/`position_y`/`position_z`/`orientation` touched, `rotation0-3` left
alone (same reasoning as before: the engine already tolerates a non-unit rotation quaternion by
falling back to orientation-only, confirmed by a real server log warning caught back in v1.16).
Landed in `data/sql/db-world/updates/2026_08_21_02.sql` (renamed from an initially-chosen `_01`
after AzerothCore's updater rejected it as a duplicate filename against another module,
`mod-bounty-board`, which happened to ship its own same-day `2026_08_21_01.sql` — the updater
tracks applied files by bare filename across *all* modules combined, not scoped per-module, a real
constraint worth remembering for any future same-day migration in any module on this server), one
`UPDATE` per stone with the
destination name as an inline comment for traceability, applied to the local DB and re-linted after
every batch rather than saved for one final pass at the end.
- **All 74 confirmed present and updated** (`SELECT COUNT(*) FROM gameobject WHERE id BETWEEN 913001
AND 913074` = 74) before considering this closed.
- This was always meant to be a real, in-person pass rather than another automated heuristic --
no vmap/terrain-height query tool is available to this assistant, so a GM physically standing at
each spot and reading real coordinates off the client is the only way to get this right, exactly as
"Next steps" item 9 anticipated when it was written.
### v1.24: destination sync + stone rotation follow-up
**A false start, corrected before it shipped.** The first read of "the orientations don't seem
right" plus "we need [something] too" was misread as "stones should open a travel menu on
interaction, like the summoned portal does" -- built that (widened
`ShowWaygateContinentMenu`/`ShowWaygateDestinationMenu` to `WorldObject*`, added a real
`OnGossipSelect` to `go_waygate_standing_stone`), then the user clarified: they didn't want stone
interaction to change at all, they wanted the *teleport destination* `.waygate` sends you to, to
actually match where the stone now stands. Fully reverted (`git diff` against the last commit came
back empty) before implementing the real ask -- recorded here rather than silently dropped, since
misreading "we need X too" as a UI-flow request instead of a data-sync request is a mistake worth
remembering the shape of.
**The real gap**: v1.23 fixed where each stone *stands* (`gameobject.position_x/y/z/orientation`),
but never touched `mod_waygate_network_locations` -- the table `.waygate`'s own teleport logic
(`FinishWaygateChannel`, `WaygateDestination.x/y/z/o`) actually reads from. That table still held
each destination's *original* coordinates from before the stones ever existed (the same ones the
stones themselves used to spawn directly on top of, back before v1.17's offset) -- so travelling to
a destination landed you at the old spot, not next to the stone you can now see marking it.
- **Fixed by computing a point 1 yard in front of each stone**, along the stone's own `orientation`
(`new_x = stone_x + cos(stone_o)`, `new_y = stone_y + sin(stone_o)`), with arrival orientation
facing back toward the stone (`stone_o + pi`, normalized). Computed directly from the 74 stones'
current, real `gameobject` rows (`913001-913074`) via a small script
(`gen_destination_offsets.py`) -- a deterministic function of data v1.23 already gathered, so this
didn't need another in-person walkthrough. Landed in
`data/sql/db-world/updates/2026_08_21_03.sql`.
- **Takes effect immediately, no restart needed** -- unlike almost everything else that's needed one
this session, `mod_waygate_network_locations` already has its own live GM reload path
(`.waygate reload` -> `LoadWaygateDestinations()`), since it was built with config-style live
reloading in mind from early on (v1.6). Worth remembering as the one piece of this module's data
that *doesn't* need a restart to pick up SQL changes -- everything gameobject/creature/quest-shaped
does.
- **The "stone orientation didn't stick" report turned out to be two separate, stacked issues.**
First, the same live-reload problem as v1.22's buried Aemos: a raw `UPDATE` to `gameobject` changes
the DB row, but an already-spawned live object doesn't retroactively re-read it -- needs an actual
respawn (restart, or the grid naturally unloading and reloading). The user restarted to rule this
out. Second, a real, distinct find *after* that restart: the "Ancient Stone Marker" model itself
renders 90 degrees ahead of whatever `orientation` is stored, confirmed by checking multiple stones
and finding the same fixed offset each time. See "v1.25: mesh orientation offset" below for the fix.
### v1.25: mesh orientation offset
The user's own diagnostic process here is worth recording as the template for this kind of bug:
restart first to rule out stale data (v1.24's leading theory), *then* check whether the remaining
mismatch is a consistent, fixed offset across multiple stones (confirms a mesh/rendering quirk) or
inconsistent (would point back to bad data). A consistent +90-degree gap on every stone checked
confirmed the former.
- **Root cause: the "Ancient Stone Marker" GameObject model (`displayId 7789`) renders its own front
90 degrees ahead of whatever `orientation` value is stored** -- a property of the model asset
itself, not anything server-side. Nothing to do with `rotation0-3` (still `(0,0,0,0)`/invalid on
every stone, unchanged since v1.16) or the load-time fallback computed from `orientation`
(`ObjectMgr.cpp`) -- that fallback was already correctly turning the stored value into a rendered
rotation, just 90 degrees off from what the model's own front face implies.
- **Fixed by storing 90 degrees (`pi/2`) less on every stone**, so `stored + the mesh's own +90` lands
back on the direction each `.gps` reading originally intended:
`new_orientation = (old_orientation - pi/2) mod 2*pi`. Computed directly from each stone's current
`gameobject.orientation` (still holding the user's original `.gps`-derived values, confirmed
unchanged since `2026_08_21_02.sql`) via a small script, not re-asked of the user.
`data/sql/db-world/updates/2026_08_21_04.sql`.
- **`mod_waygate_network_locations` (v1.24's destination sync) deliberately left untouched.** Its
"1 yard in front of the stone" offset math reads a stone's `orientation` to mean "the direction a
player intended when they took the `.gps` reading," and that computation was already confirmed
correct (arrival position and facing matched intent) *before* this mesh-offset correction existed.
Recomputing it against the new, mesh-corrected `orientation` value would silently break something
that was already right -- the two consumers of a stone's `orientation` column turned out to want
different things from it (visual mesh facing vs. logical "which way does this stone point"), and
only the visual one had a bug.
- Requires the same restart/respawn as any other `gameobject` change to actually render -- not yet
re-confirmed in-game as of this write-up.
## Custom ID ranges
- **Text/data ID 911000** (gossip header `npc_text`, not attached to any permanently-spawned
creature)
- **Creature ID 911001** (`npc_waygate_portal`) — summon-only, never has a static `creature` spawn
row, only ever exists transiently via `Player::SummonCreature`
- **Creature IDs 911002-911004, 911006** (v1.15, roster changed in v1.20) — the 4 persistently-placed
Ley Wardens quest-chain NPCs (Aemos, Windcaller Tyese, The First Warden, and Borin Ironquill at
`911003` -- repurposed in place from "Echo of the Ley Wardens" in v1.20), real static `creature`
spawns unlike the portal above. `911005` ("Nazrek the Bent") is retired as of v1.20 and no longer
in use. `quest_greeting` reuses these same entries directly (keyed by NPC entry, confirmed no
separate ID range needed for greeting text).
- **Creature ID 911007** (v1.20) — "The Ironbound Sentinel," the kill-credit combat objective on
quest `912001`.
- **Quest IDs 912000-912099** (v1.15, restructured in v1.20) — reserved for this module;
`912000-912004` are the five Ley Wardens quests as of v1.20 (`912005` retired, not reused), room
left for a future second chain without renumbering.
- **`gameobject_template` IDs 913001-913074** (v1.16) — one Waygate Stone per destination
(`entry = 913000 + mod_waygate_network_locations.id`), see "v1.16: Waygate Stones" above.
- **Text/data ID 911012** (v1.18) — gossip flavor text for an un-reactivated stone, continuing the
flat `911xxx` text/data space alongside `911000`.
- **`mod_waygate_network_locations` IDs 1-8** are the original capital cities, **9-10** are Shattrath
City / Dalaran, **11-27** are the Eastern Kingdoms quest-hub batch (v1.11), **28-45** are the
Kalimdor batch (v1.12), **46-61** are the Outland batch (v1.13), **62-74** are the Northrend batch
(v1.14), all referenced by existing `mod_waygate_network_discovered` rows — don't reassign them.
New destinations start at 75 (table uses `AUTO_INCREMENT`, so plain inserts without an explicit
`id` are safe).
Spell ID 910000 is retired (see the spell → command pivot above) — its rows are removed via
`data/sql/db-world/updates/2026_08_13_01.sql` and `data/sql/db-characters/updates/2026_08_13_01.sql`.
Don't reuse 910000 without checking those files first.
Spell ID 8690 (`Hearthstone`) is no longer referenced by this module at all — v1.7's
`spell_waygate_network_teleport` `SpellScript` attached to it is deleted (see "v1.8: dropping the
spell-hijack cast"), and `data/sql/db-world/updates/2026_08_15_03.sql` removes the
`spell_script_names` binding v1.7's `2026_08_15_02.sql` had added. This module reserves no real
spell ID at all as of v1.8.
Checked against `data/sql/base/db_world/spell_dbc.sql` / `creature_template.sql` at time of writing;
re-check before adding more IDs, following the same discipline as `mod-dragon-legacy` (900000–900099
spell / 901000–901099 creature range).
## Next steps (agreed direction, 2026-08-14 planning session)
Agreed order — do these roughly in sequence, since #1 changes how destinations are stored and
everything after it should just work against whatever that ends up being, rather than against the
current hardcoded 8:
1. **Data-driven destinations — done (v1.3, pending in-game re-test).** `WaygateDestinationList` now
loads from `mod_waygate_network_locations` (world DB) at startup instead of being hardcoded, so
adding a destination is a SQL insert, not a code change + rebuild. No `.reload` command yet —
needs a worldserver restart to pick up new rows. Unblocks #2.
2. **More locations: major quest hubs per zone — all four continents done (v1.11-v1.14), pending
in-game re-test.** Shattrath City and Dalaran landed first (v1.6) mostly to exercise the continent
grouping and cross-map cost path; v1.11-v1.14 followed with real, verified batches of 17 Eastern
Kingdoms, 18 Kalimdor, 16 Outland, and 13 Northrend hubs (64 new destinations total) — see "v1.11:
Eastern Kingdoms quest-hub batch" through "v1.14: Northrend quest-hub batch" above for the method.
Full-continent coverage is done; what's left is a shorter list of zones skipped for
naming-confidence or data-consistency reasons across the four passes, each worth a manual
follow-up whenever there's appetite for it: Stonetalon Mountains (v1.12, no confident name match
at all), Isle of Quel'Danas and Nagrand's Alliance side (v1.13), and Icecrown (v1.14 — the one
promising candidate resolved to a zone id tagged to a different map, too ambiguous to trust). None
of these block anything else; the module works fine without them, they're just gaps in an
otherwise complete map. Content curation, not a code change.
3. **A wait before travel completes — done (v1.8), pending in-game re-test.** Went through a full
architecture change: v1.7 shipped a real 10s cast borrowed from the real Hearthstone spell (8690)
for a genuine cast bar, but that turned out unreliable in more realistic play (real effect/cooldown
leaking through despite guards) and was dropped entirely in v1.8 for a plain server-controlled
`WaygateNetwork.ChannelMs` hold-still wait with a cosmetic visual instead — no real spell involved.
See "v1.7: real cast time" and "v1.8: dropping the spell-hijack cast" below for the full story.
4. **Distance-scaled gold cost — done (v1.5), pending in-game re-test.** Framed as an alternative to
flight paths, not a replacement — deliberately priced a bit above a comparable flight.
`CalculateWaygateCost` uses straight-line distance × `WaygateNetwork.CostPerYard` for same-map
trips, a flat `WaygateNetwork.CrossMapCostCopper` for cross-continent trips (Eastern
Kingdoms/Kalimdor/Outland/Northrend don't share a coordinate space), and
`WaygateNetwork.MinCostCopper` as a floor. Displayed inline in the gossip destination list via
`FormatWaygateCost`, broken down as gold/silver/copper with real coin icons (WoW's
`|Ttexture:size|t` inline texture markup — confirm rendering in-game like everything else this
module has touched).
- **Real flight prices were actually checked, not guessed.** `taxipath_dbc`/`taxipathnode_dbc` are
both empty (0 rows) in the local dev world DB, same problem as the sound ID — but this client's
actual binary `env/dist/bin/dbc/TaxiPath.dbc` (915 records) was parsed directly. Findings: flight
cost is a **static price baked into the DBC per node-pair** (`TaxiPathEntry::price`, see
`src/server/shared/DataStores/DBCStructure.h` and `ObjectMgr::GetTaxiPath` /
`Player::ActivateTaxiPathTo` in core) — **not** derived from a distance formula at runtime, so
there's no canonical formula to replicate exactly. Real prices range ~50c (Stormwind ↔
Ironforge) to ~12000c for the longest single-continent (Northrend-internal) legs, averaging
~2367c, and aren't a clean function of distance.
- `WaygateNetwork.CostPerYard` defaults to `0.02`, calibrated against the one real price point
actually available (Stormwind ↔ Ironforge, ~4217yd straight-line, 50c real) — puts our cost at
~84c, ~1.7x. Longer/shorter routes will drift from that exact ratio since straight-line distance
underestimates real (curved) flight-path distance. Explicitly a starting point to tune against
what's actually observed in-game, not a scientific calibration — same epistemics as the sound ID.
- Debug mode (item in "Admin/debug tooling" below) travels free, recalculated/re-checked
server-side at teleport time rather than trusting whatever was shown when the menu opened (the
player could have moved in between).
- `Player::ModifyMoney` does **not** fail/return false on insufficient funds for a negative amount
— it silently clamps spending to 0 (see `Player.cpp`). Had to add an explicit
`HasEnoughMoney()` check before calling it; would have silently let players travel for free
otherwise. Worth remembering for any other module wanting to charge a player money.
5. **Sound on unlock — done (v1.4 mechanism, v1.9 ID).** Wired via a hand-built `SMSG_PLAY_SOUND`
packet (no `Player::PlaySound` helper exists in this core — confirmed by grepping for it; every
use, e.g. `Map::PlayDirectSoundToMap`, builds the packet by hand, so this module does too). Shipped
disabled at first (`WaygateNetwork.UnlockSoundId = 0`) since the local dev world DB's
`soundentries_dbc` SQL mirror table was empty; defaults to `1519` (`TaxiNodeDiscovered`) as of
v1.9 — see "v1.9: animation + confirmed visual/sound IDs" above.
6. **Hint the macro trick to players — done (v1.4).** Confirmed no server-side "auto-create a macro"
is realistic — macros are an opaque client-synced blob, not a server-editable table — so this is
just a one-line chat tip ("type .waygate... you can even bind it to a macro, e.g. '/say .waygate'")
appended to a character's very first-ever attunement message only, not every one.
7. **Short intro quest line + lore — done (v1.15), pending in-game testing.** "The Ley Wardens," a
6-quest, dialogue-only, no-combat chain. Turned out to be more than pure narrative framing in the
end — the user's own explicit direction pushed it into a real mechanical gate (`.waygate` now
requires completing it, see "v1.15: The Ley Wardens" above), not just flavor layered on top of an
already-working feature the way this item originally assumed it would land. See that section for
the full design: the questgiver (Aemos, a faction-neutral ancient construct), the narrative hook
(an ancient order predating the Kirin Tor, itself predated by something on Outland), the NPC
roster, and the ID ranges reserved.
8. **Interactable attunement objects — done (v1.16), pending in-game testing.** Replaced passive,
walk-into-the-zone attunement (`WaygateNetwork_Player::OnPlayerUpdateZone`) with a real, physical
**Waygate Stone** at each of the 74 destinations that a character must actually find and interact
with to attune — fits the Ley Wardens lore (the quest chain's own text already calls these things
"standing stones") tighter than the old "just be in the zone" trigger. See "v1.16: Waygate
Stones" below for the full design (chosen object, per-destination template entries instead of a
shared one, the zone-entry hint that replaced auto-attunement) and for the still-deferred
"clear existing attunements/quest-chain completion for real players" cleanup — the
random-bot-vs-alt-bot limitation already written up below is unchanged and still applies whenever
that migration actually gets written.
9. ~~**MANUAL TODO for the user, not automatable: hand-tune every Waygate Stone's position.**~~ **Done,
2026-08-21 — see "v1.23: manual stone repositioning" below.**
## Admin/debug tooling (agreed 2026-08-14, done in v1.4)
Not player-facing features — QoL for whoever's actually building/testing content (curating #2's
quest-hub locations, tuning #4's costs, etc.). Both items below shipped in v1.4 (not yet re-tested
in-game as of this writing).
- **`.waygate reload`** — re-runs `LoadWaygateDestinations()` against `mod_waygate_network_locations`
without a full worldserver restart. Directly resolves v1.3's "needs a restart to pick up new rows"
limitation. Implemented as a subcommand nested under `.waygate` (`{"", HandleWaygateCommand, ...}`
as the default/no-subcommand handler, `{"reload", HandleWaygateReloadCommand, SEC_GAMEMASTER, ...}`
alongside it — the `""`-named-entry pattern used all over core, e.g. `cs_learn.cpp`/`cs_npc.cpp`)
rather than a separate top-level command. `SEC_GAMEMASTER`, not `SEC_PLAYER` like the base command.
`Console::Yes`, so it's runnable from the server console too.
- **Debug "show all locations" mode.** `WaygateNetwork.Debug` config toggle (off by default)
that makes `.waygate` list *every* row in `mod_waygate_network_locations`, not just the ones the
character has actually attuned — useful for testing new destinations or reviewing the full list
without needing to physically visit each one first. Debug entries are tagged `|cffff0000[debug]|r`
in the menu so they're visually distinct. GM-only regardless of the config flag (`IsWaygateDebugFor`
checks `player->GetSession()->GetSecurity() >= SEC_GAMEMASTER` on top of the config check), so
flipping the config alone can't let a regular player skip attunement — also applies on the
travel-select side (`OnGossipSelect` skips the `mod_waygate_network_discovered` re-validation for
the same GM check), not just the listing.
- General home for further "admin QoL, not a player feature" ideas as they come up during content
curation (#2) and everything after it — doesn't need to all land at once.
## Other ideas (not sequenced)
- **Better portal visual/animation.** `npc_waygate_portal` reuses an existing "Alterac Valley
Portal" display ID as a placeholder — a custom model, spawn VFX, or emote on summon would sell the
moment better than the current bare reused visual.
- **More destination types beyond quest hubs**, once #2 above is done: flight point towns, dungeon
entrances, or player-owned "recall" points.
## Companion addon (explicitly a last step, investigated 2026-08-14, not committed)
User's ask: a client-side UI addon that shows the destination picker as a proper list + a live map
preview of the selected destination, FFXIV-Aetheryte-style, instead of (or alongside) the plain
gossip menu. Investigated feasibility, not yet started — do this only after everything else above.
- **Don't hook/reskin the default Gossip frame.** Given this module's own history (gossip-menu
client rendering has already burned us twice — see "v1.1" and "v1.2" above), the addon should draw
its own independent frame rather than depending on how the stock `GossipFrame` happens to render
our menu. Trigger it however the addon wants (its own button/keybind/slash command), fully
decoupled from `.waygate`'s gossip path.
- **Two real options for getting the destination list to the addon** — checked both against this
actual codebase rather than assuming:
- **The real WoW addon-message channel** (`CHAT_MSG_ADDON` + a registered prefix,
`SendAddonMessage`/`RegisterAddonPrefix` client-side). Confirmed AzerothCore's core supports
sending these server→client — already used elsewhere in this exact codebase by
`CreatureTextMgr`/`SmartScript` for encounter/boss-mod-style data feeds (grep
`src/server/game/Texts/CreatureTextMgr.cpp`, `src/server/game/AI/SmartScripts/SmartScript.cpp`
for precedent). This is the clean approach — no chat-window flicker, structured data.
- **Text-scraping over system chat**, the pattern `modules/mod-llm-chatter` already built and uses
for its own (undistributed, docs-only) companion addon: `SendSysMessage` with a
`"CHATTER_ADDON "` line prefix + percent-encoding (see `LLMChatterCommand.cpp`'s `SendAddonLine`/
`PercentEncode`), with the addon hooking `ChatFrame_AddMessageEventFilter` to intercept and hide
those lines from the visible chat frame. Hackier than the real addon-message channel, but proven
and already has working reference code in this repo. `mod-llm-chatter`'s own addon Lua/TOC files
aren't actually checked into the repo though (confirmed zero `.lua`/`.toc` files repo-wide as of
this writing) — only a design doc (`modules/mod-llm-chatter/docs/chatter-addon-reference.md`)
describing what the addon *would* do, so there's no complete addon reference implementation to
directly copy from this repo, just the server-side half of the pattern.
- Either way, **the actual teleport trigger doesn't need either data channel** — the user's
`.waygate-direct ` idea works via a plain `SendChatMessage(".waygate-direct ",
"SAY")` from Lua, same "dot-commands are just server-intercepted chat text" mechanism already
relied on for the macro-hint tip (item 6 above). Only the *list* (and the map data, if live)
actually needs a real data channel.
- **The map-with-a-pin piece is a separate, harder sub-problem** from the list — needs zone/continent
map texture assets plus WotLK's pixel↔world-coordinate conversion math. Well-trodden territory
from the WotLK addon era (Atlas/Cartographer-lineage addons solved exactly this), but it's real
UI-craft work, not pure logic. Recommend scoping v1 of the addon as list-only, adding the live map
preview as a follow-up layer — same incremental approach used for the rest of this module.
- **Distribution is fundamentally different from everything else in this module.** Addons are 100%
client-side files (`Interface/AddOns//`, Lua + XML + a `.toc`) — nothing server-side can
auto-install one on a player's client. On this server that just means handing players a folder/zip
out of band; it's opt-in tooling, not something guaranteed running for every character the way
`.waygate` itself is.
- Genuinely a different skillset (Lua/XML UI dev) from the C++/SQL work this module has been so far
— the 3.3.5a client API is old and stable/well-documented, but this is real, separate work, not a
quick bolt-on once the above items are done.
## Deferred / rejected (investigated, not module-shaped)
- **Gossip menus sourced from the player's own GUID (no NPC involved at all).** Investigated in
depth via this module's own v1.1 → v1.2 pivot (see above) — technically supported server-side
(`HandleGossipSelectOptionOpcode`'s `guid.IsPlayer()` branch), but the client's Gossip UI doesn't
appear to render it, based on in-game testing where no menu ever appeared. A real (even if
transient/summoned) Creature or GameObject is required as the gossip sender for the menu to
actually show up. Filed here so this lesson doesn't need re-discovering per-module, alongside the
custom-spell-ID one below.
- **Brand-new custom spell IDs for anything player-facing (spellbook/cast bar/macro).** Investigated
in depth via this module's own v1 → v1.1 pivot (see above) — `spell_dbc` is server-only, the client
needs its own local `Spell.dbc` entry to render anything, and no client patch is distributed here.
Only two ways around it: reuse an already-client-known ID (inherits that ID's real name/icon,
can't be renamed from the server), or ship a client-side patch (out of scope for a
server-only module). Filed here so this lesson doesn't need re-discovering per-module.
- **A dated `updates/` migration file that depends on a table created by the same module's own
`base/` file, in the same apply pass.** Not hit here (yet) — `updates/2026_08_13_01.sql` only
touches `character_spell`, a core table that already exists. But `mod-dragon-legacy` hit this
exact trap: the module SQL updater sorts *all* files across `base/` + `updates/` by filename
alone (`UpdateFetcher::FillFileListRecursively`), not by directory, so a date-stamped
`updates/2026_08_14_01.sql` sorted before its own module's `base/dragon_legacy_unlocked.sql` and
tried to `INSERT`/`DELETE` against a table that didn't exist yet — the whole update batch aborted.
Fixed there by folding the migration into the `base/` file itself instead (idempotent, safe to
rerun). Worth checking filename sort order before this module's own "Next steps" #1 (locations
table) ships a base table + updates migration together.
- **Reusing/derestricting Mage `Teleport:` spells for all classes.** Technically trivial (teach +
zero out `spell_reagent`), but rejected as the starting design — it's a balance change to an
existing class kit dressed up as a feature, not additive. `Call Waygate` is its own spell instead.
- **FFXIV terminology** ("Aetheryte" etc.) — intentionally avoided; landed on "Waygate" since it's
already a canon WoW term (the Dalaran sewer portal), so it reads as native lore rather than an
import.