# 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.