Core

Contributor63 min readUpdated

In short

Lifecycle, the message bus, settings, commands, localisation, the compiled guide data, the guide registry and the engine.

On this page
  1. Lifecycle
  2. Message bus
  3. Settings
  4. Commands
  5. Caught errors and the Polestar Discord
  6. Frames under the game's UI
  7. Gamepad interface
  8. Localisation
  9. Names
  10. Guide texts
  11. Compat — the client's game data goes through here
  12. Map frames
  13. Core services: Data, Hooks, Search
  14. Guide data (compiled Lua) — the toolchain/Engine contract
  15. The guide registry (Core/Guides.lua)
  16. Engine
  17. Smart logic
  18. Focus: one person at a time
  19. Route choice ("Choose your path")
  20. Tabs (several guides at once)

local ADDON, ns = ... in every file; Polestar = ns is set once in Core/Init.lua (first file loaded). Packs reach it as the global.

Lifecycle#

lua
Polestar.OnInit(fn)    -- ADDON_LOADED("Polestar"). Saved settings are loaded; Get falls back to defaults.
Polestar.OnLogin(fn)   -- PLAYER_LOGIN, in registration order. Player info valid.

SavedVariables may come back nil after a cold client restart on Forever beta builds (reproduced by the community quest database, build 69913, file on disk intact). Never derive guide progress from saved state alone: the current step is always recomputed from live quest state. Saved state only holds preferences, the chosen guide, and manual check-offs; losing it must degrade gracefully.

Message bus#

lua
local h = Polestar.On(msg, fn)   -- fn(...) ; returns handle
Polestar.Off(h)
Polestar.Fire(msg, ...)
Polestar.RegisterEvent(event, fn)    -- game events, one shared hidden frame; fn(event, ...)
Polestar.UnregisterEvent(event, fn)

Messages:

MessageArgsFired by
GUIDES_REGISTERED—Core, once at PLAYER_LOGIN after packs registered (and installed packs scanned)
PACKS_CHANGEDpackIdGuides.SetPackShown: a pack's guides were hidden or shown
GUIDE_CHANGEDguideId or nilEngine
STEP_CHANGEDstepIndexEngine
STEP_UPDATEDstepIndexEngine (goal status/progress changed, same step); Names (names arrived)
NAMES_LOADED—Names, when the names pack loads after a refusal in combat (the quest tracker reads its names again)
NAME_LEARNEDkind ("npc" | "object"), idNames, when a name learned in play is new or changed (the Codex, Dungeons and ItemDB caches drop their copy)
STEP_SKIPPEDfromStep, toStep or nil, { { quest, title, reason, text }, ... }Engine, active tab, just before STEP_CHANGED: the computed step moved past a step whose open quest goals became unavailable (reasons as in "Unavailable quests")
PLAYER_SKIPPED_STEPguideId, stepIndexEngine, after Skip() (the window, the menu, /polestar skip): the guide window offers the undo
WAYPOINT_CHANGEDwaypoint or nilEngine (and Nav when an override is set/cleared)
GUIDE_COMPLETEDguideIdEngine
GUIDE_PLACEDguideId, stepIndex or nil, { guideId, ... }Engine, active tab, once placement of a guide just opened ends (see "Placement"; not for a guide a login or /reload brings back): where the guide resumes, and the guides passed whole on the way; the guide window goes over the steps before it, titles and count rolling (GF.PlayCatchUp, after PLAYER_ENTERING_WORLD)
CATCH_UPtrue | falseGuide window, while its catch-up goes over the steps (GF.PlayCatchUp): the arrow shows an hourglass turning over in its dial's place (Arrow.SetBusy; Media/UI hourglass, hourglass-sand, tools/media/ui.js)
SETTING_CHANGEDpath, valueCore settings
TOGGLE_FRAME / SHOW_FRAME / HIDE_FRAME—commands, keybinds → UI
SHOW_PICKER—commands → UI
SHOW_OPTIONSsection or nilcommands → UI
SHOW_WELCOMEpage or nilAbout's "Show the welcome again", /polestar welcome → UI/Welcome.lua
SHOW_COPYtitle, text/polestar where → UI/Copy.lua
SHOW_REPORT—/polestar report → UI/Report.lua
SHOW_DISCORD—/polestar discord → UI/Report.lua
SHOW_IMPORT—/polestar import (Core/Import.lua) → UI/Import.lua: the hub at Import
TOGGLE_PICKER / TOGGLE_OPTIONS—key bindings and gamepad chords (POLESTAR_MENU, POLESTAR_OPTIONS) → UI: open, or close when open
TOGGLE_FOCUS—POLESTAR_FOCUS → UI/GamepadGuide.lua (focus mode)

Modules fire their own (TABS_CHANGED, ROUTE_*, TRAIN_DUE, CLASS_TRAINED, GUIDE_OUTLEVELED, QUESTDB_*, CODEX_LOADED, ITEMDB_LOADED, GEAR_UPDATED, FLIGHT_STARTED/FLIGHT_ENDED, QOL_HEARTH_CHANGED, QOL_XP_UPDATED, MOUNT_SPOTTED, RARE_SPOTTED, RARES_HISTORY, SHOW_LOOKUP, SHOW_DUNGEON, SHOW_RARES), documented with their module.

Settings#

lua
Polestar.RegisterDefaults(section, tbl)  -- at file load; section = "ui" | "nav" | "behavior" | ...
Polestar.Get("ui.scale")                 -- dotted path; falls back to defaults
Polestar.Set("ui.scale", 1.1)            -- writes PolestarDB.settings, fires SETTING_CHANGED
Polestar.CharState()                      -- PolestarCharDB, always a table
Polestar.IsNewCharacter()                 -- true when there was no saved PolestarCharDB as the addon loaded
Polestar.FormatDistance(yards, opts)      -- "120 yd", or "110 m" / "1.2 km" under nav.distanceUnit = "metres"

Every distance a player reads goes through FormatDistance (the arrow, the map target's tooltip, the death helper, the rares' toasts and view); text that embeds one takes the formatted string (%s). opts.round = 5 rounds to the nearest 5, opts.compact writes yards from 1000 as "1.2k yd" (Rares.YardsText); metres turn to kilometres from 1000 m. The bug report keeps yards for whoever reads it.

Read the rest of this section496 words

PolestarDB = { schema = 1, settings = { [section] = { ... } }, packs = { [packId] = { off = true } }, imports = { [guideId] = { format, license, guide, frames } }, welcomeSeen = 1, gamepad = { chords = { [action] = "PADRSHOULDER+PADDUP" } } } PolestarCharDB = { schema = 1, guide = id|nil, tabs = { guideId, ... }, activeTab = n, absentAt = { [guideId] = place }, manual = { [guideId] = { ["s:g"] = true|"tick" } }, skipped = { [guideId] = { [stepIndex] = true, ["s:g"] = true } }, pins = { [guideId] = { step, done } }, offers = { [questID] = true }, shapes = { [guideId] = shape }, stepKeys = { [guideId] = { hash, ... } }, flags = { fp = { [name] = true }, ... }, welcomeSeen = 0|1 }

packs[<id>].off hides a pack's guides account-wide (the Community guides page's switch). imports holds the guides pasted on the Import page, as decoded ("The guide registry", imported guides). welcomeSeen (UI/Welcome.lua) is the version of the welcome last seen: in PolestarDB, the account has had its welcome (later characters skip its Set up and Route style pages); in PolestarCharDB, this character has (0 while due). behavior.suggestFrom (default "guides") is the pack suggestions come from. Modules keep their own keys beside these (rares, instances, names account-wide; ui, routes, classTrainLevel, classTrainVisit, qol, flightTimes, flightNow, instanceRuns per character), each described in its section or file header.

manual holds the player's ticks ("tick", any goal but a note) and latched results (true): NPC interactions (talk/train/vendor) are latched during their step, and conditions that can stop holding later (goto, sethome, hearth, fly, getfp, buy, bag-count collect, train class with nothing to learn) are latched once the route moves past their step, so leaving a zone, rebinding the hearthstone or levelling up never drags the guide backwards. A hearth or fly that landed is also latched within its step while that step is the live one, even while the player looks at another step, so walking out of the arrival's subzone does not send the player back to the flight master (#362); a step only looked at ahead never latches it, and stepping back (Prev) keeps it. Quest goals are never latched, but for a direct turn-in's QUEST_TURNED_IN during its step (a repeatable is never flagged completed): abandoning a quest reopens its step (unless the player ticked it). skipped holds the steps the player skipped (step number) and the optional goals they skipped ("s:g"). pins is the step the player was looking at when it is not the live one (Prev/Next/Goto), offers the quests their giver did not offer (saved at logout, restored while the character is still that level). All of them are keyed by step position: stepKeys (a hash per step of its verbs and ids) lets a pack update carry them to the steps it kept (see "Placement").

Commands#

lua
Polestar.RegisterCommand(name, fn(argString), help)

/polestar and /pole. No argument fires TOGGLE_FRAME. help lists registered commands with their help lines (already translated: L["..."]). Core registers (Core/Commands.lua): guides, options, show, hide, next, prev (look at the next/previous step; nothing changes), skip (skip the step shown, for good), reset (clear ticks, latches, skips and the saved view for the current guide), guide <id>, status, tabs, tab <n>, close, path (the route choice), welcome (SHOW_WELCOME), packs (Options at Community guides), gamepad (Options at Gamepad), where, report (SHOW_REPORT), discord (SHOW_DISCORD), debug; Nav adds arrow, way and route; modules add theirs (xp, gear, instances, dungeons, lookup, mount, rares, questdb, import (SHOW_IMPORT, Core/Import.lua)).

where prints the player's map and position as two guide lines, zone map <uiMapID> and goto <x>,<y> live (two decimals, the client's live frame, marked live because typed coordinates are read in the Era frame), then "For another goal, add @x,y live", and fires SHOW_COPY with the two lines. In an instance or on a map without a position it prints why instead.

Caught errors and the Polestar Discord#

lua
Polestar.SafeCall(fn, ...)    -- runs fn; an error is noted, then handed to the error handler
Polestar.NoteError(err)       -- notes an error Polestar caught (SafeCall, Hooks' callbacks)
Polestar.Errors() -> { { msg, count, seq }, ... }   -- this session's, oldest first, the last 5 kept
Polestar.NoteBlocked(addon, fn)   -- notes a protected call the game blocked from Polestar or Polestar_*
Polestar.Blocked() -> { { addon, fn, count, at, seq }, ... }   -- this session's, oldest first, the last 5 kept
Polestar.DiscordURL           -- the Polestar Discord's invite

Every message and game-event handler, init and login callback and command runs through SafeCall, and Hooks' callbacks through their own pcall, so the errors Polestar catches are noted by their first line, counted, for the report a player copies (UI, "Reporting a problem"). Polestar never replaces the game's error handler: wrapping it would shift the stack the game and !BugGrabber show for every error. Taint is no Lua error: ADDON_ACTION_BLOCKED and ADDON_ACTION_FORBIDDEN (the addon blamed, then the function) go to NoteBlocked, which keeps those blaming Polestar or a Polestar_* addon with the addon, how often and the time it last came (%H:%M:%S), so a report on a broken gamepad or button does not read "Errors: none". seq, one counter for both lists, says which came last.

Frames under the game's UI#

lua
Polestar.CreateFrameUnder(kind, name, parent, template) -> frame   -- CreateFrame with no parent, then SetParent(parent)

Forever's gamepad interface hooks CreateFrame (Blizzard_GamepadSmartNavigation, SetupFrameHooks): when the parent is, or is under, one of its open panels (the world map, the quest frame, the talents, the settings...), it rebuilds that panel's button groups inside the caller's code. Called from Polestar, the panel's tables are left tainted and the next gamepad action there is blocked ("An action was blocked because of taint from Polestar"; seen in game as a quest reward was taken, then on every panel after it). SetParent is not hooked. So every frame of Polestar's whose parent is a game frame, or is under one (the map canvases, the minimap, the quest reward and talent buttons, the XP bar, and the frames under those), is made through CreateFrameUnder; frames under Polestar's own windows, which hang off UIParent, are made with CreateFrame. No CreateFramePool with a game frame either: it calls CreateFrame with its parent. tests/lua/wow_stub.lua's CreateFrame raises on a call from addon code whose parent is, or is under, a frame addon code did not make (stub.underGameFrames), and tests/nav-mapcanvas.test.js, tests/qol.test.js (the reward marks, the XP bar mark) and tests/talents.test.js (the talent marks) check that none was made.

Gamepad interface#

lua
Polestar.GamepadUI() -> bool   -- Forever's Gamepad interface style is on (read once, as the addon loads)
Polestar.CloseOnEscape(name)   -- add a Polestar window to UISpecialFrames, except with the gamepad interface
Polestar.RunCommand(msg)       -- what /polestar runs; registered as a slash command only with the keyboard and mouse

With Forever's Gamepad interface on (Options, Gamepad, Enable Gamepad UI; C_InputInterfaceStyle, which decides: the CVar InputDeviceInterfaceStyle can stay at Gamepad with the keyboard and mouse, so it is read only on a client without the API), its navigation (Blizzard_GamepadSmartNavigation, FrameControlsManager) opens, closes and focuses the game's windows, popups, menus and chat box from Blizzard's code, and in the same execution makes protected calls (SetPreferredGamepadInteractTarget, RunBinding, DescendStop). Addon code running in that flow gets them blocked, and the "AddOn blocked" popup's own gamepad handling blocks the next one, thousands of times, until the client freezes. An addon whose only code is an empty slash command reproduces it (Blizzard forum threads 2367777 and 2370253). Other Forever addons (EllesmereUI, Olympus, the community quest database) do the same as Polestar: with the gamepad interface on, they keep to their own windows.

Read the rest of this section348 words

GamepadUI() reads the style once, as Core/Init.lua loads (the client reloads the UI when it changes). With it on, Polestar:

  • registers no slash command: /polestar, /pole, /way (Nav/Journey) and /psrec (Polestar_Recorder, which reads the style itself). The chat box calls a command's function, then closes itself in that function's taint. The minimap button, on by default then (maps.minimapButton), and the key bindings open Polestar; the Recorder page calls PolestarRecorder_Command;
  • adds no entry to the world map and minimap tracking menus (Menu.ModifyMenu, Nav/Nearby);
  • puts no window in UISpecialFrames (CloseOnEscape), which the gamepad's menus read and close;
  • opens no world map (Compat.OpenWorldMap; a dungeon page has no "Show on map") and adds no canvas click handler to it (Nav/Journey);
  • never closes the game's Settings panel for "Open Polestar settings" (UI/Options: the chat says to close it);
  • hooks no button the gamepad's navigation moves over: no Polestar line on talent tooltips (Talents/Overlay), no route on the flight map (QoL/Flight; the node keeps its mark), no quest levels in the game's quest windows (Quests/QuestLevels, whose surfaces lay out windows the gamepad manages);
  • leaves the game's request dialogs for the player (QoL/Social) and the error-message setting off (QoL/Toggles, which writes the game's BLACK_LISTED_MESSAGE_TYPES);
  • sets no override binding on the gamepad's buttons: a Polestar window holding the gamepad reads them through its own frame only; binds no chord unasked and names every chord on LB or RB as the game's own; and only reads the gamepad's focused window, never calls into its managers (UI/Gamepad, see Gamepad).

Frames under the game's frames are made through CreateFrameUnder and the maps are only read (MapCanvas) with either style. The quest automation, the vendor and the trainer stay on: they call the game's API, never its windows, and the game opens and closes those from its own event handlers. tests/gamepad-ui.test.js loads Polestar with each style, and lists every line of addon code that reaches into the game's UI (slash commands, UISpecialFrames, menus, panels, popups, the world map's tables, the error frame's settings, override bindings, the gamepad's managers): a new one fails there until it is guarded and listed.

Localisation#

Polestar.L[key]: a key is the English text, and a missing key returns itself, so a client without a translation reads English. A locale file (Locales/<locale>.lua, one per language of tools/lib/i18n/locales.js, loaded by Core/Core.xml right after Core/Init.lua and returning at once on another client) fills L for its client, so every file after it may read L at load; Init.lua, which loads before it, reads L only when it prints (the commands register in Core/Commands.lua). docs/TRANSLATING.md describes the files and node tools/polestar.js i18n.

A word with more than one meaning gets a key per meaning: Polestar.LC(context, text) is the translation of "<context>: <text>", and text while there is none: a dungeon boss that is a chest is LC("boss", "Chest").

Read the rest of this section287 words

A game term the client names itself is shown in the client's words and has no key: reputation standings (Polestar.StandingName(n), FACTION_STANDING_LABELn in the character's gender through GetText), item qualities (Polestar.QualityName(q), ITEM_QUALITYn_DESC), the factions (Polestar.FactionName(f), FACTION_ALLIANCE, FACTION_HORDE), gear slots (Gear.SlotName, HEADSLOT…) and the Dungeon Journal (ENCOUNTER_JOURNAL). The test client (tests/lua/wow_stub.lua) holds their English values. Class and race names come from the client too, keeping their keys only as the fallback: UI.ClassName reads LOCALIZED_CLASS_NAMES_MALE, UI.RaceName reads C_CreatureInfo.GetRaceInfo by the Era races' ChrRaces IDs. Skyborne reads its key: the client names it only per side ("High Order Skyborne", "Windshaper Skyborne").

Every player-facing string is written as a literal L["..."] or LC(...) where it appears, so i18n keys and a translator find every key: a table of labels holds L["Home"], not "Home" translated later through L[VIEW_LABEL[v]]. L[variable] is left only for text that comes from data (a guide's category, race and class names, profession and spec names, the talent plans' spec names, the release stage), each listed in DATA_KEYS (tools/lib/i18n/keys.js, which also reads Gear/Weights.lua's spec labels, Talents/Builds.lua's specs and the Dungeons attunement notes of tools/lib/codex/attunements.json). Generated files carry their prose as literal keys too: talents build writes the plan summary and per-level notes as L["..."] in Talents/Builds.lua. Data that names a game thing carries its id instead, and the display asks Polestar.Names (quests, NPCs, objects, items, spells, factions, instances, areas, flight points, encounters). The optional Recorder has its own ns.L and Locales/<locale>.lua (listed in its TOC); i18n check and sync cover it too.

Polestar.AI_LOCALES names the languages whose translation is AI-made (status: "ai" in locales.js; tests/i18n.test.js keeps the two equal). On such a client with its file loaded, Polestar.AITranslation() is true and Options > About says so, with the Discord invite.

Names#

Polestar.Names (Core/Names.lua) names quests, NPCs, objects, items, spells, factions, areas, instances, flight points and dungeon encounters in the client's language. The guides and data packs keep their English; where a name is shown or matched, the caller asks Names first and keeps its English when the answer is nil:

text
Names.Quest(id) / Names.Npc(id) / Names.Object(id) / Names.Item(id) -> name | nil
Names.Spell(id) / Names.Faction(id) / Names.Area(areaID) / Names.Instance(mapID)
  / Names.Taxi(id) / Names.Encounter(id) -> name | nil
Names.Get(kind, id) -> name | nil      -- the above by kind: quest npc object item
                                       -- spell faction area map taxi encounter,
                                       -- else the kind's pack table
Names.Cached(kind, id) -> name | nil   -- never asks the server; items from the pack only
Names.Learn(unit) -> name | nil        -- keeps the name the client shows for unit
Names.Localised() -> bool              -- false on enUS and enGB
Names.Ready() -> bool                  -- the pack is in, or will not load this session
Names.PackName() -> "Polestar_Names_<locale>" | nil
Names.Add(locale, kind, { [id] = name })   -- the pack's files only; any kind

Order

The client's own name first, where the client has an API by id: a quest's title (Compat.GetQuestTitle, C_QuestLog.GetTitleForQuestID), an item's name (Compat.GetItemName, C_Item.GetItemNameByID), a spell's (C_Spell.GetSpellName, GetSpellInfo), a faction's (C_Reputation, GetFactionInfoByID; a faction the character has not met falls to the pack) and an area's (Compat.GetAreaName). An instance is the pack's first (map, Map.csv), then GetRealZoneText(mapID) when that names somewhere other than the current zone (an older client ignores the argument). Flight points (taxi) and encounters are the pack's only. Then the names pack of the client's language, Polestar_Names_<GetLocale()>, loaded through Data.Pack on the first ask (refused in combat, it loads when combat ends, fires NAMES_LOADED and redraws the guide window once; a missing folder is final for the session and harmless). Then nil. On an English client every answer is nil and nothing loads.

Learned in play

The client has no API for NPC or object names by id, but it names the units it shows: on PLAYER_TARGET_CHANGED, UPDATE_MOUSEOVER_UNIT, NAME_PLATE_UNIT_ADDED and the NPC windows (GOSSIP_SHOW, QUEST_GREETING, QUEST_DETAIL, QUEST_PROGRESS, QUEST_COMPLETE, MERCHANT_SHOW, TRAINER_SHOW: the npc unit, a quest-giving object's GUID too) Names.Learn keeps the unit's name by its GUID's id in PolestarDB.names[<locale>].npc / .object (account-wide, per client language, read through ns.DB() each time; player-controlled units are skipped; a name equal to the pack's is not kept; at most 2,000 ids, order oldest first, the oldest tenth dropped past the cap). Names.Npc and Names.Object answer a learned name before the pack's. A new or changed one fires NAME_LEARNED(kind, id), and redraws the guide window once when the current or next step names that id. The Codex's decoded records, the open dungeon page and ItemDB's parsed quests and sources drop their copies on NAME_LEARNED and on NAMES_LOADED.

Asking the server

A quest or item the client has not cached is asked for once a session (Compat.RequestQuest, Compat.RequestItem); the pack's name, or the English, shows meanwhile. When asked names arrive (QUEST_DATA_LOAD_RESULT, ITEM_DATA_LOAD_RESULT), one STEP_UPDATED per burst refills the guide window's rows.

Consumers

The guide window's labels (Engine describe, the quest title order: on a non-English client the client's title before the guide's English one; skill goals' professions through L, learn and craft goals' spells and rep goals' factions by id; the class trainer of Engine.NearestClassTrainer), the target button's /targetexact (QoL/Buttons.lua), the quest database's names (QuestDB.GetQuestTitle, EntityName, GetNpc, GetItem, GetItemDroppers, GetObjectiveMobs: tooltips and the target button's droppers), objective matching (Quests/Tracker.lua: a client line is matched on the translated name before falling back to order, and object tooltips find their objectives by the translated name), the Codex's records (Codex.Item/Npc/Object/Quest, an instance's bosses and Codex.Name for those kinds: so Lookup, its sources, the item-tooltip spotter and Rares read them translated), a dungeon page as it is decoded (Dungeons/Dungeons.lua: quest titles, start and finish points, reward and starting items, keys, bosses by encounter, NPC or object, attunement names), the gear advice's leveling data (ItemDB.ItemName, Quest, Sources), and Search: on a non-English client each Codex kind's index gets, once, a line per id whose translated name (or, for zones, the client's map name) differs, a match counts an id once and is listed under its translated name. The collect goals' compiled sources carry their ids (fromId, fromAlsoId, of kind fromKind), so the goal line names them in the client's language; from and fromAlso are the English for older packs and English clients.

The packs

One load-on-demand addon per language (addon/Polestar_Names_<locale>/: Names.lua calling Names.Add per 1,000 names and kind, the TOC, LICENSE-DATA.txt, Ding's CREDITS.md), committed and shipped. Kinds: quest, npc, object, item, and map (instance maps), encounter (DungeonEncounter ids), faction, taxi (TaxiNodes ids) and objective (the objective text of the dungeon quests whose objective is an event). node tools/polestar.js i18n names builds them, per id the first source with a name winning: the client's own tables in that language (the game-table export with &locale=: Forever's ItemSparse, Map, DungeonEncounter, Faction and TaxiNodes, then the Era client's ItemSparse for the items Forever's lacks; the newest build holding every one in English and each language, and none stops the build), the web database's Forever database in that language (read from data/sources/<web-db>/names; without that cache this source is empty; a bracketed name is no translation), the community names database (cloned into data/sources/<names-db>, pinned to a commit and its files' sha256s: DING in tools/lib/i18n/names.js), then the Era quest database's db/<locale> (no esMX). A name equal to the English one, from any source, is never stored: a translation from a later source wins, else the English shows as it would; a name marking unused content ([PH], [UNUSED], (DNT), <NYI>, (test) and their translations) is none. Areas need no pack: the client names them by id (Compat.GetAreaName). Only the ids the compiled guides, the quest database, the Codex's data and the travel data name are kept. --no-<quest-db> leaves the community names database out. Data.CheckInstall checks the client's pack like the other optional data packs: never missing, named when its version differs.

Guide texts#

Polestar.GuideTexts (Core/GuideTexts.lua) shows a guide's own words in the client's language. A compiled guide's texts (its title, why, pitch, pros, cons and reward text; each step's title and note; each goal's note and text) carry the game's names as tokens, {kind:id}, and the guide's names hold each token's English (tools/lib/dsl/tokens.js; docs/PACK_FORMAT.md, "Format 3"). RegisterGuide hands each guide to GuideTexts.Prepare before escaping it, which rewrites every text in place as the pack's translation into the client's language (else the English) with each token filled: Names.Cached for quests, NPCs, objects and items (never asking the server), Names.Get for spells, factions and areas, the client's map name for a zone, L for a profession; else the token's English. A guide's folder and the pack's title and notes are the pack's own texts; a folder named after a class reads as LOCALIZED_CLASS_NAMES_MALE's.

text
GuideTexts.Prepare(def, live) / GuideTexts.Refresh(def) -> changed
GuideTexts.Load(addon) -> "loaded" | "combat" | nil
GuideTexts.TextsAddon(addon) -> "<addon>_<locale>" | nil   -- nil on English
GuideTexts.PackText(packId, text) -> translation | text
Polestar.RegisterGuideTexts(locale, packId, { pack = {...}, guides = { [id] = {...} } })
  • Loading.The translations of a pack are its texts addon for the client's language, <pack addon>_<locale> (load on demand, generated by compile from the pack's locales/<locale>/ files): only that one loads, only on that client, through Data.Pack, at the pack's own ADDON_LOADED. While the pack's files load, its guides show their English names (asking Names then would load the names pack in the middle of another addon's files); at its ADDON_LOADED Core/Guides.lua loads the texts addon and refreshes the pack's guides, their headers and the pack's title and notes, with the names. A texts addon refused in combat loads on PLAYER_REGEN_ENABLED; a names pack landing late (NAMES_LOADED) refreshes every guide; a refresh after login fires GUIDES_REGISTERED, so the guide window, the menus and search read the new texts.
  • English clients fill the tokens once at registration and keep nothing else; no texts addon loads. A text in the guide's written (one spelling a name otherwise than its token's English, "Dendrite" for Dendrite Starblaze) reads as the author's words there.
  • Data.CheckInstall checks the client's texts addon of our pack like the names pack: never missing, named when its version differs.

Compat — the client's game data goes through here#

UI, Nav and Engine read the client's quests, map, units and items through Polestar.Compat, with these exceptions that call the client themselves: Nav's Blizzard user waypoint (C_Map, C_SuperTrack; Nav/Bridges.lua), the minimap's radius, shape, rotation and indoors state (Nav/Pins.lua; Maps/RouteLines.lua reads them through Pins.MinimapView()), the gamepad's map cursor and A (SoftCursor, IsKeyDown; Nav/Pins.lua), the map frames Polestar draws on (only read; Nav/MapCanvas.lua), the run speed, taxi nodes, hearthstone cooldown and ghost state (GetUnitSpeed, C_TaxiMap, item cooldown, UnitIsGhost; Nav/Travel.lua), the player's height for the walking engine (Compat.GetPlayerUnitPosition, Nav/Walk.lua), the zone art's map art layers and exploration (UI/ZoneArt.lua), the class name in the options (UnitClass, UI/Options.lua), quest level colours (GetQuestDifficultyColor, UI/Dungeons.lua), item names, icons and quality colours (C_Item; UI/Dungeons.lua, UI/GearPick.lua, UI/Lookup.lua, Rares/View.lua), the game's item and spell tooltips and modified clicks on Polestar's item and spell buttons (GameTooltip:SetHyperlink, SetSpellByID, HandleModifiedItemClick, ChatFrameUtil.InsertLink; W.ItemTip, W.SpellTip, W.ItemClick, W.SpellClick in Theme/Widgets.lua), the travel button's secure action button (SecureActionButtonTemplate, CooldownFrameTemplate), its item and spell icons, names and cooldowns (C_Item.GetItemIconByID, GetItemIcon, C_Spell.GetSpellName, GetSpellInfo, C_Spell.GetSpellTexture, GetSpellTexture, C_Container.GetItemCooldown, GetItemCooldown) and IsPlayerSpell when Compat has no IsSpellKnown (Nav/Prompts.lua), the transport alerts' game sound and taskbar flash (PlaySoundFile on the Master channel, FlashClientIcon; Nav/Alerts.lua), and the flight map's node slots and hop lines (GetTaxiMapID, C_TaxiMap.GetAllTaxiNodes, TaxiNodeGetType, GetNumRoutes, TaxiGetNodeSlot, DrawLine on TaxiRouteMap; QoL/Flight.lua), and the transit sync's addon messages and who hears them (C_ChatInfo.RegisterAddonMessagePrefix, C_ChatInfo.SendAddonMessage, CHAT_MSG_ADDON, IsInGuild, IsInGroup, IsInRaid, IsInInstance, GetNormalizedRealmName, UnitName, Ambiguate; Nav/Transit.lua), the hand-off to Shortest Path Forever (ShortestPathForever.API, UnitIsDeadOrGhost; Nav/Handoff.lua), map clicks, /way and the TomTom stand-in (Nav/Journey.lua: IsShiftKeyDown, IsAltKeyDown, IsControlKeyDown, GetMouseFoci or GetMouseFocus, the open chat box through ChatFrameUtil.GetActiveWindow or ChatEdit_GetActiveWindow, WorldMapFrame:AddCanvasClickHandler (kept in Blizzard_MapCanvasSecureUtil's registry, which calls addon handlers through securecallfunction), GetNormalizedCursorPosition, ScrollContainer:IsMouseOver, the GLOBAL_MOUSE_DOWN event, Minimap:HookScript("OnMouseUp"), C_Map.GetMapInfo, C_Map.GetMapInfoAtPosition, C_Map.GetBestMapForUnit, C_Map.GetMapChildrenInfo, hooksecurefunc on TomTom's own table, the TomTom global and SlashCmdList), the cursor on the minimap (GetCursorPosition; Pins.MinimapCursor(), Nav/Pins.lua), the nearby services' tracking menus (Menu.ModifyMenu, Nav/Nearby.lua) and the gamepad and key binding API (C_GamePad, C_InputInterfaceStyle, C_KeyBindings, SetBinding, GetBindingKey, SetOverrideBindingClick, IsKeyDown; UI/Gamepad.lua). Frame and system calls (GetTime, GetServerTime, C_Timer, InCombatLockdown, the cursor, CVars, addon loading, the build and locale) are not routed either. Polestar_Nav is a separate addon whose router (started from Shortest Path Forever's) has no Compat, so it calls the client directly: C_EncodingUtil.DecodeBase64 and C_EncodingUtil.DecompressString with Enum.CompressionMethod.Zlib (decoding a map cluster, Routing/PathGrid.lua), debugprofilestop (the frame budget), C_Timer.After, InCombatLockdown with a CreateFrame frame waiting for PLAYER_REGEN_ENABLED, and geterrorhandler (Routing/PathJobs.lua). The world map is opened only through Compat.OpenWorldMap(uiMapID) (C_Map.OpenWorldMap, the global OpenWorldMap only on a client without it, never in combat; Compat.CanOpenWorldMap() says whether it can): the global opener called from addon code taints the map frame (NAV_QA C3), and tests/compat.test.js rejects any other code that opens or moves the world map. Routing the rest keeps Forever specifics in few places and lets other modules' tests replace Compat with a fake.

Coordinates: map percent x, y in 0–100. World coordinates follow C_Map.GetWorldPosFromMapPos: wx is the north–south axis (larger is north), wy is the east–west axis (larger is west), in yards.

lua
Compat.IsSecret(v)              -> bool   (a secret value: comparing, indexing or parsing it throws)
Compat.Readable(v)              -> v | nil (a secret value reads as unknown)
Compat.Now()                    -> seconds since the epoch, server clock
Compat.GetPlayerInfo()          -> { name, realm, guid, level, xp, xpMax, race, class, faction }
                                   -- race = raceFile ("Human","Scourge",...), class = classFile ("WARRIOR"), faction = "Alliance"|"Horde"|"Neutral"
Compat.GetPlayerFaction()       -> "Alliance" | "Horde" | nil  (UnitFactionGroup; nil when neutral, unread or secret;
                                   -- the walking router's profile, Nav/Walk.lua)
Compat.GetPlayerMapPosition()   -> uiMapID, x, y | nil      (nil in instances / unknown)
Compat.GetPlayerWorldPosition() -> continentID, wx, wy | nil  (via MapToWorld of the above)
Compat.GetPlayerUnitPosition()  -> wx, wy, z | nil, instanceID | nil  (UnitPosition; the one place it is read; nil while secret;
                                   -- z nil also when the client reports exactly 0, as Forever's does)
Compat.MapToWorld(uiMapID, x, y, frame) -> continentID, wx, wy | nil
                                   -- frame "e": use registered Era frame for uiMapID (see Map frames); nil: live client
Compat.WorldToMap(continentID, wx, wy, uiMapID) -> x, y | nil
Compat.GetMapName(uiMapID)      -> string | nil
Compat.MapName(uiMapID)         -> string | nil   (the client's name, else the Codex zone's)
Compat.GetAreaName(areaID)      -> string | nil   (C_Map.GetAreaInfo: the area's name in the client's language)
Compat.GetContinentMap(uiMapID) -> continent uiMapID | nil
Compat.GetPlayerFacing()        -> radians | nil   (0 = north, counter-clockwise positive)
Compat.GetZoneText()            -> zone, subzone
Compat.IsQuestCompleted(questID)       -> bool   (flagged completed / turned in)
Compat.IsOnQuest(questID)              -> bool
Compat.IsQuestReadyForTurnIn(questID)  -> bool
Compat.GetQuestObjectives(questID)     -> { { text, type, finished, numFulfilled, numRequired }, ... } | nil
Compat.GetQuestTitle(questID)          -> string | nil (client cache; may be nil)
Compat.GetOfferedQuests(source)        -> { questID, ... } | nil   (source "gossip" | "greeting" | "detail")
Compat.IsQuestTrivial(qlvl, level)     -> bool   (grey for that level)
Compat.GetItemCount(itemID, includeBank) -> number   (includeBank: the bank's too)
Compat.GetItemQuality(itemID)          -> 0..7 | nil (nil while not cached)
Compat.ItemQualityHex(quality)         -> "ffrrggbb" | nil
Compat.ItemIcon(itemID)                -> icon | nil (C_Item.GetItemIconByID; no cache needed)
Compat.SpellIcon(spellID)              -> icon | nil
Compat.ItemLink(itemID)                -> link | nil (nil while not cached)
Compat.RequestItem(itemID)             -> bool   (cached; else requested: GET_ITEM_INFO_RECEIVED follows)
Compat.SpellLink(spellID)              -> link | nil
Compat.RequestSpell(spellID)           -> bool   (cached; else requested: SPELL_DATA_LOAD_RESULT follows)
Compat.GetBindLocation()               -> string | nil
Compat.IsOnTaxi()                      -> bool
Compat.GetCurrentTaxiNode()            -> name of the open taxi map's node | nil
Compat.GetNpcID(unit)                  -> number | nil   (from UnitGUID)
Compat.InCombat()                      -> bool
Compat.IsInInstance()                  -> bool, instanceType ("party" | "raid" | "pvp" | ... inside)
Compat.IsIndoors()                     -> bool | nil (the client's indoor state; nil without IsIndoors or when secret)
Compat.IsShiftKeyDown()                -> bool
Compat.AddOnPresent(name)              -> bool   (installed and enabled: loaded, or loadable after Polestar)
Compat.Supports(feature)               -> bool   ("skill" | "spell" | "faction" | "explore" | "achievement")
Compat.GetSkillRank(skillLineOrName)   -> rank, maxRank | nil  (profession/secondary skill)
Compat.IsSpellKnown(spellID)           -> bool
Compat.GetSpellCooldown(spellID)       -> start, duration (GetTime seconds; 0, 0 when ready or secret)
Compat.GetFactionStanding(factionID)   -> standingID (1 Hated .. 8 Exalted), value, barMin, barMax, name | nil
Compat.IsAreaExploredAt(uiMapID, x, y) -> bool | nil   (C_MapExplorationInfo)
Compat.GetAchievement(id)              -> { completed, name, criteria = { { text, completed, cur, max }, ... } } | nil

Map frames#

Guide coordinates from Era-sourced data are in the Era map frame. Four Forever maps changed frame versus Era (the community quest database evidence: Mulgore, Eastern Plaguelands, Redridge Mountains, Stormwind City), and more may. Packs embed the Era world bounds for every map they reference:

lua
Polestar.RegisterMapFrames({ era = { [uiMapID] = { c = continentMapID, left = wy0, right = wy1, top = wx0, bottom = wx1 }, ... } }, packId)

packId nil is Polestar itself (its data addons, class trainers). Frames from Polestar and our pack (guides) win for a map both cover, whatever the load order; another pack only adds maps they lack (lint warns where its frame differs).

For frame "e": wx = top + (bottom - top) * y/100, wy = left + (right - left) * x/100. Compat.MapToWorld(map, x, y, "e") uses that; everything downstream (distance, arrow, pins) works in world coordinates and converts back to live map percent with Compat.WorldToMap. Coordinates without f are live-frame (recorded in Forever) and use C_Map.GetWorldPosFromMapPos directly.

Shared by every module (summarised in docs/SUITE.md, "Runtime services"); each file's header lists its full API.

lua
Polestar.Data.Pack(name) -> loaded, reason|nil   -- C_AddOns.LoadAddOn once per session; never in combat ("combat", retried later)
Polestar.Data.Info(name) -> { loaded, reason, memoryKB }

Polestar.Hooks.ItemLines(owner, fn(tooltip, link, itemID))       -- GameTooltip, ItemRefTooltip
Polestar.Hooks.UnitLines(owner, fn(tooltip, unit))               -- GameTooltip
Polestar.Hooks.ObjectLines(owner, fn(tooltip, objectID, name))   -- GameTooltip; either may be nil
Polestar.Hooks.ItemClick(owner, fn(link, itemID))                -- modified clicks on chat links, bag and loot items
Polestar.Hooks.AddLine(tooltip, left, right?, r, g, b, rr, rg, rb)

Polestar.Search.Query(text, opts) -> results, counts, more   -- opts = { kinds, limit = 60, perKind, offset, subtitles = false }
   -- result = { kind, id, title, subtitle, quality, score, status, curated }; exact > prefix > word prefix > substring
Polestar.Search.Subtitle(result) ; Search.KINDS ; Search.Status() -> codexLoaded, reason

Hooks owns the client hooks: TooltipDataProcessor.AddTooltipPostCall (Item, Unit, Object) and a hooksecurefunc post-hook of HandleModifiedItemClick, which bag buttons, loot buttons and chat links (SetItemRef) all reach. One entry per owner, callbacks in registration order, each in its own pcall; forbidden tooltips and secret values are skipped. Search matches guides (titles, keywords, folded once per guide list) and the Codex kinds through the pack's prebuilt index (Codex.Index(kind): one string of folded names per kind), so a keystroke is a few plain string.find calls and never decodes a record. Each kind reads at most 200 candidates per query, best tiers first (more[kind] says there were more); a one-letter query matches name prefixes only. Only the results returned are named, and subtitles are filled per shown row (Search.Subtitle). Generated lookup/ guides are not listed.

Measured with the real pack (lua5.1, tests/lookup-codex.test.js setup): the pack loads in about 20 ms (4.4 MB; it never loads at login), the first query costs 7 ms (guide headers), a keystroke 1.5–4 ms (1 to 8 letters), a detail page (Lookup.Sources) 0.1 ms for a mount up to 10 ms for an item with 40 drop sources.

Guide data (compiled Lua) — the toolchain/Engine contract#

Generated by tools/polestar.js compile from .guide sources (docs/GUIDE_FORMAT.md) into the pack's addon folder: the generated TOC, Pack.lua, MapFrames.lua, Guides.lua (one RegisterGuide per guide, each built in its own function) and LICENSE-DATA.txt, plus LICENSE.txt (the pack's LICENSE) when the pack folder has one. Ours is addon/Polestar_Guides/; a community pack's is <pack>/dist/<folder>/. The full contract for pack authors' tools is docs/PACK_FORMAT.md.

lua
local ADDON = ...
Polestar.RegisterPack({ addon = ADDON, id = "guides", format = 2, title = "Polestar Guides",
  author = "Polestar", version = "0.2.0", license = "GPL-3.0-only" })   -- optional: website, source, notes

Polestar.RegisterGuide({
  id = "forever/alliance/human/01-elwynn-forest",
  pack = "guides",
  title = "Elwynn Forest",
  faction = "Alliance",            -- "Alliance" | "Horde" | nil (either)
  races = { "Human" },             -- raceFile list or nil (any)
  classes = nil,                   -- classFile list or nil (any)
  minLevel = 1, maxLevel = 13,
  category = "Leveling",
  zone = 1429, zoneName = "Elwynn Forest",
  next = "forever/alliance/02-westfall",   -- or nil
  author = "Polestar", version = 1,
  group = true,                    -- or nil: a goal of the route (not optional) needs a group
  reward = { { spell = 23161, spellName = "Summon Dreadsteed" } },  -- or nil: what the goal gives (format 2)
  why = "...",                     -- or nil: one line on why it is worth doing (format 2)
  steps = {
    {
      title = "Deputy Willem",     -- optional
      note = "...",                -- optional, player-facing
      only = { classes = { "WARRIOR" }, races = { "Human" }, factions = { "Alliance" } },  -- optional, AND across keys, OR within
      when = { { k = "level", op = "<", n = 6 }, { k = "onquest", q = { 788 } } },  -- optional (format 4): conditions checked during play, all must hold
      along = "camp",              -- optional (format 4): true or a later step's label; the route passes over the step
      label = "camp",              -- optional (format 4): the step's name, unique in the guide
      goals = { goal, ... },       -- at least one
    },
  },
})
Read the rest of this section1453 words

Goal fields. Common: t (verb), text (display override), note, optional (true = never blocks the step), only (same shape as step only), map, x, y (0–100), f ("e" = Era frame, absent = live), r (arrival radius in yards), group (group content, below). A goal placed at an NPC or object that spawns at several places carries spawn areas, spots = { { x, y, r, n, alt }, ... }: cluster centre in the goal's map and frame, radius in yards, spawn count; together they cover every spawn of the target on that map. Camps nearest to the goal's x/y first, at most 6 (a target with 6 spawns or fewer gets a spot at every place it spawns); with spread = true (an area objective, or a few places far apart) up to 12 spread over the whole area, as a walk from x/y. The other places follow with alt = true; a hand-placed point is the first spot, the data's places its alternatives (GUIDE_FORMAT, "Spawn areas"). A loop step's goals carry its goto points instead, in order, with spread = true and no n or alternatives (compile-time: resolve.js applyLoop). x/y stay the default point. z (optional, 0.2.4) is the height of the NPC or object the goal's x/y stands for (the compiler's, from spawnz.json: GUIDE_FORMAT, "Heights"); a spot and a smart target's candidate may carry their own z (0.2.5, #180), by the same rule from their own point. The Engine puts the height of the place it leads to on the waypoint (posOf: the chosen candidate's z, else the chosen spot's, else the goal's while the waypoint is the goal's own point), and on the step view's goals and groups, so a click on a goal's row, header or map pin and the training reminder's and Nearby's class trainer (Nav.SetTarget's z) keep it; the walk to it is searched with it ("Walking paths" under Nav). Packs stay format 2: an older Polestar ignores it.

Group content (format 2; GUIDE_FORMAT, "Group content"): group = { kind = "elite", rank = 1|2|3 }, { kind = "dungeon"|"raid", name = "Dire Maul" } or { kind = "group", size = 3 } (size optional), derived by tools/lib/dsl/group.js (elite work; the solo filter's instance rule, tools/lib/sim/solo.js) unless the author wrote group [N]. Display only: the Engine carries it into the goal view as party (the view's group is the goal's field group) and the guide window says it under the goal (UI.GroupLine: "Elite: group advised", "Dungeon: Dire Maul", "Group of 3 advised"), once per card. The guide's group = true (some non-optional goal has group) is the guide menu's "Group" chip.

Escort quests (GUIDE_FORMAT, "Escort quests"): escort = true on an escort quest's accept, complete and objective goals, derived by tools/lib/dsl/escort.js. Display only: the Engine carries it into the goal view as escort and the guide window says it under the goal (UI.EscortLine: "Escort: if the NPC is not there, wait for it to respawn"), once per card. Older Polestar ignores it.

A guide's reward (format 2; GUIDE_FORMAT, reward) lists what its goal gives: { item, itemName, quality, choice }, { spell, spellName } or { text }; its why is one line on why. Display only: the guide menu's details lead their facts with them (UI/Picker.lua): the entries given outright under "Reward", the choice ones under "Choose one", each a tile with the item's or spell's icon (Compat.ItemIcon, Compat.SpellIcon; a glyph for words and unknown ids), an item's name in its quality colour (the client's when cached, else the compiled quality) and the game's item or spell tooltip on hover (W.ItemTip, W.SpellTip: set again when an uncached item or spell arrives while the tile still owns the tooltip; the pane requests its items and spells as it paints them); a modified click acts as on a bag item (W.ItemClick: Shift links, Ctrl tries it on, Alt guides; W.SpellClick: Shift links the spell); then "Why".

tRequiredOptionalDone when (Engine)
acceptquest, titlenpc/npcName, object/objName, item/itemName, minLevel, qlvl, pre, preAll, closedBy, preTitleson quest or completed
turninquest, titlenpc/npcName, object/objName, rewards = { choices = { { item, itemName, quality } }, items = { ... }, xp, money } (copper), directcompleted; direct (never accepted, so never orphaned): its QUEST_TURNED_IN during the step (latched) or the completed flag, not the session's turn-ins, so one hand-in of a repeatable closes one goal
completequest, title—ready for turn-in or completed
killtargetnpc, count, quest, oiits objective finished (the quest-log line named after target, else line oi), or quest ready/completed (not while the log lists no objectives yet, right after the accept); without quest: manual
collectitem, itemNamecount, quest, oiobjective finished (the line named after the item, else oi) / quest ready (as kill) / completed; without quest: item count ≥ count, or, for a single item the step's next accept … item starts its quest with (paired as in the step view), that quest in the log or done (accepting uses the item up)
gotomap, x, ytext, r (default 8 yd), area (its name in the client's language shows instead of text)within radius, or gone into its area (subzone text equals the client's name of it, not the zone's, while indoors, after being seen elsewhere since the step came up; not for a goto after one into the same area in the step), or any later goal in the step is done, or, when only gotos, notes and goals that do not apply follow it in the step, the next step that applies is done with a quest goal of it done (Own.nextStepDone, counted as anchorStep counts: not a skipped step or one with only, not a goal with only; a cave's mouth or a dock ahead of work done ahead of the guide, #240)
talknpc, npcName—gossip/quest frame opened with that NPC during this step; else manual
interactobject, objNamequest, oiobjective state if quest (the line named after the object, else oi); else manual
useitem, itemNamequest, oiobjective state if quest; else manual
sethomelocationnpc, npcName, areabound at the goal's inn (npc's, else the inn location names, in Nav/Travel.lua's data), by any of: the inn Travel finds for GetBindLocation() (Nav.BoundInn: the innkeeper learned at bind time in flags.inn, else the inn whose area the client names so, else its names); GetBindLocation() equal to the client's name of that inn's or the goal's area (Compat.GetAreaName); last, equal to location (case-insensitive). Binding at that innkeeper during the step latches it. Engine.IsSethomeInnkeeper(goal, npc) says whether npc is that innkeeper (for sethome nearest, one of its candidates); QoL's bind helper asks it
hearthlocationareazone or subzone equals the client's name of area (without one, location), else manual; latched once it lands in its live step
getfplocationnpc, npcName, node, areawith node: that taxi node among the known ones (flags.taxi, recorded by Nav/Travel.lua from the flight map); without: taxi map opened at a flight master of that name (persisted in flags.fp); else manual
flylocationarea, nodeas hearth, including the in-step latch (node: the flight point, named in the client's language where no area names the place)
train—npc, npcName, texttrainer window opened during this step, else manual
buyitem, itemNamecount, npc, npcNameitem count ≥ count (default 1)
vendor—npc, npcName, textmerchant window opened during this step, else manual
levellevelfracplayer level > level, or = level with XP / XPMax ≥ frac (no frac: level ≥ level)
notetext—never blocks (informational)
skillskill (name), levelskillLineskill rank ≥ level
learnspell, spellNameskillLine, npc/npcName (trainer), item (recipe item)spell known
craftspell, spellNameitem/itemName (created), count, untilSkill, skillLine, reagents = { { item, itemName, count } }skill ≥ untilSkill if set, else created-item count ≥ count, else manual
repfaction, factionName, standing (1–8)—standing ≥ standing
exploremap, x, ytext (area name)area at that point explored; else arrival (latched)
achieveachievement, achievementNamecriteria (index)achievement (or that criterion) completed

Objective index oi is 1-based, matching C_QuestLog.GetQuestObjectives. Field names npc, object, item, quest always hold numeric IDs. Objective goals also carry the quest's title; accept carries minLevel from the data when the author gave none, qlvl (quest level), the blocking prerequisites pre (one of) and preAll (every one), closedBy (the quest its breadcrumb leads to and the quests exclusive with it: once any of them is taken or done the giver never offers it again) and preTitles (titles of the quests those name, for the reason texts). Every goal of a quest only characters with a profession can take (quests.json skill, skillLevel: the community quest database's Forever requiredSkill; the Era quest database's Classic skill is not used for a quest the Forever store lists) carries profLine (skill line), profName and profRank (1 when the data has no level): the Engine treats the goal as not this character's (status "na", like another class's goal) while Compat.GetSkillRank reports no rank or a lower one, unless the quest is in the log or done or the client cannot read skills, and checks again on SKILL_LINES_CHANGED. only !Class compiles to the list of the other eight classes, so compiled guides never contain negations.

The guide registry (Core/Guides.lua)#

Every guide belongs to a registered pack. The rules a pack meets (ids, no overrides, clashes, map frames, format, problems, literal text, packs on disk and their statuses) are in PACK_FORMAT.md, "Rules Polestar enforces"; lint applies the same ones (tests/packs-parity.test.js). Where Core/Guides.lua implements them:

  • Ids: Guides.RESERVED (reserved pack ids) and Guides.OwnsId (which prefix a pack owns: forever/ and draft/ for guides, lookup/ for the internal lookup pack, registered with internal = true and never listed or switchable, every <author>/ that is a valid, unreserved pack id for imported (below), <id>/ for every other pack). An id another pack's guide holds is refused, so an imported guide never takes a pack's place.
  • Clashes: guides and map frames a refused pack's files register are dropped until that addon's ADDON_LOADED; guides registered before their pack are refused, their ids kept in a per-pack list and listed as guide-refused problems when it comes.
  • Format: Guides.FORMATS (the formats this Polestar reads, 1 to 4); Guides.Validate checks each guide against the REQUIRED verb table (Guides.VERBS).
  • Problems: each refusal goes on the pack's record, problems = { { kind = "pack-refused"|"guide-refused"|"no-guides", guide?, message } }, and to ns.Debug.
  • Literal text: ns.EscapeText (| doubled), ids excepted.
  • Packs on disk: Guides.ScanInstalled, at login before GUIDES_REGISTERED, gives every addon whose TOC has X-Polestar-Pack and never registered a record from its TOC.
Read the rest of this section512 words

A pack record: id, addon, format, title, author, version, license, website, source, notes, official (ours), imported (the imported guides' pack), status (the statuses of PACK_FORMAT rule 9; off from PolestarDB.packs[id].off), reason, needs, problems, guides (ids), guideCount.

lua
Polestar.RegisterPack(meta) -> ok, err
Polestar.RegisterGuide(def) -> ok, err      -- def.pack must be registered and own def.id; validates shape
Polestar.RegisterMapFrames(frames, packId)
Polestar.Guides.Packs() -> { record, ... }  -- every pack but internal ones and `imported`, ours first, then by title
Polestar.Guides.Pack(id) -> record | nil
Polestar.Guides.PackShown(id) -> bool
Polestar.Guides.SetPackShown(id, shown)     -- PolestarDB.packs[id].off; fires PACKS_CHANGED(id)
Polestar.Guides.ProblemCount() -> n         -- over every listed pack (the Options dot)
Polestar.Guides.OwnsId(packId, guideId) -> bool
Polestar.Guides.SuggestSources() -> { packId, ... }  -- "guides", then shown packs with leveling guides (never `imported`)
Polestar.Guides.SuggestSource() -> packId   -- behavior.suggestFrom when it can, else "guides"
Polestar.Guides.OfferedBy(id) -> { guide, ... }      -- shown guides whose route choice offers id now (Engine.RouteOffered)
Polestar.Guides.DropImported(id) -> bool    -- takes an imported guide out (Import.Delete), nothing else

Imported guides (Core/Import.lua; the string's format: PACK_FORMAT.md, "Import strings"). A guide shared as one line of text and pasted on the Import page belongs to the reserved pack imported, which Import.lua registers (imported = true) as its file loads, before any pack can. Its guides keep their authors' ids (yendi/cozy-sleeping-bag), carry license (the string's) and are kept in PolestarDB.imports[id] as decoded. Guides.lua's login handler calls Import.Load after ScanInstalled and before GUIDES_REGISTERED, so every stored guide goes through the same checks as a pasted string with every community addon known. The reader: the client's C_EncodingUtil.DecodeBase64 and DecompressString (zlib, as Polestar_Nav's maps; a client without them is told it cannot read strings), Adler-32 over the payload, a JSON parser of its own (no null, 12 levels deep at most), then the guide built field by field from a fixed schema of the compiler's keys and types (Import.KEYS; tests/import.test.js holds it to emit.js's key lists), then the id, prefix, next, step-cap and Guides.Validate checks. Nothing in a string is run. The record is never listed on the Community guides page (Packs) nor a suggestion source; the guide menu lists it as "Imported" under Community guides, and P.Credits takes the guide's own license. Saving or deleting fires PACKS_CHANGED("imported"); a guide registered again in its place is taken by its open tab where it stands (Engine, PACKS_CHANGED: restoredTab, so ticks and skips follow the steps it kept).

lua
Polestar.Import.Decode(text) -> entry | nil, reason   -- entry = { format, license, guide, frames }
Polestar.Import.Preview(text) -> info | nil, reason   -- info: entry, id, title, author, version, steps, minLevel, maxLevel, faction, license, state ("new"|"update"|"same"|"older"), have
Polestar.Import.Save(entry) -> ok, reason             -- checks again, registers, stores
Polestar.Import.Delete(id) -> bool
Polestar.Import.List() -> { { id, title, author, version, license, steps, problem }, ... }  -- by title
Polestar.Import.Load()                                -- at login
Polestar.Import.LIMITS                                -- { length = 50000, steps = 60, payload = 262144, text = 4096 }

Only guides of shown packs (on, plus the internal lookup) are visible to Get, List, Suggestions, Suggest, OfferedBy, search and Lookup.CuratedGuide. PACKS_CHANGED refreshes the picker, search, the guide window, Lookup and the Engine's tabs ("Tabs", absent guides).

Engine#

lua
Polestar.Guides.Get(id) -> guide | nil
Polestar.Guides.List() -> { header, ... }   -- header = guide without steps, plus stepCount
Polestar.Guides.Suggestions() -> { id, ... } -- the leveling guides of the suggestion source (Guides.SuggestSource) for the player's faction/race/class/level, best first; ours fill in where it has none; none fits: the nearest upcoming one alone
Polestar.Guides.Suggest() -> id | nil       -- the first of Suggestions()

Polestar.Engine.GetGuide() -> guide | nil
Polestar.Engine.SetGuide(id)                -- persists CharState().guide, recomputes, fires GUIDE_CHANGED
Polestar.Engine.GetStepIndex() -> i | nil
Polestar.Engine.GetStepCount() -> n
Polestar.Engine.GetStepView(i) -> view | nil
Polestar.Engine.Next() / Prev() / Goto(i)    -- look at another step; changes nothing (Goto un-skips its target)
Polestar.Engine.Skip() -> stepIndex         -- skip the step shown, saved; fires PLAYER_SKIPPED_STEP
Polestar.Engine.Unskip(i) -> bool           -- undo a skip
Polestar.Engine.GetLiveStep() -> i | nil    -- the first open step, whatever step is shown
Polestar.Engine.LevelTarget(level?) -> N, stepIndex | nil   -- the first `level N` goal from the step shown for this character (step `only`/`when`, goal `only`; skips left out) with N above `level` (default: the character's); QoL's XP bar mark
Polestar.Engine.StartGate(id) -> gate | nil -- a Class guide this character cannot start yet: { level } or { pre, preTitle }, with giver, map (see "Not yet available")
Polestar.Engine.ToggleGoal(stepIndex, goalIndex) -> bool   -- tick any goal but a note done, or clear its check-off
Polestar.Engine.SkipGoal(stepIndex, goalIndex) -> bool     -- skip an optional goal, or bring it back
Polestar.Engine.AimGoal(stepIndex, goalIndex) -> bool      -- lead the arrow to a goal of the current step (row click)
Polestar.Engine.GetAlong() -> { { index, title, goals = { goal view, ... } }, ... }  -- open goals of along steps behind the live step
Polestar.Engine.AimAlong(stepIndex, goalIndex) -> bool     -- lead the arrow to one of them (row click); holds while it is listed
Polestar.Engine.GetWaypoint() -> waypoint | nil
Polestar.Engine.GetProgress() -> doneSteps, totalSteps

Smart logic#

Candidate goals

A compiled goal may carry candidates = { { npc, npcName, map, x, y, z?, f, faction = "A"|"H"|"AH", races?, classes? }, ... } (from train <Profession>, train class, buy … nearest, vendor nearest, sethome nearest; see GUIDE_FORMAT). The Engine filters by the character's faction (and races/classes when given), then picks the cheapest by Polestar.Nav.EstimateTravel(from, to) seconds when Nav provides it, else world distance on the same continent with a large penalty across continents. A candidate in a zone whose level range (Maps.GetZoneLevels) starts more than 10 levels above the character comes after every other choice, other continents included. It re-picks on step start, zone change and landing, never per frame. The goal view then carries the chosen npc, npcName, map, x, y, f, plus alternatives = n; Polestar.Engine.CycleCandidate(stepIndex, goalIndex) moves to the next-best one. Profession train goals are done when the profession's max rank reaches that rank's cap (75/150/225/300), so a trainer anywhere counts. train class is done on opening a trainer window for any candidate during the step, or when there is nothing new to learn: the character last visited its class trainer (classTrainVisit) at this level, or at the even level below an odd one, since class trainers teach on even levels only.

Spawn areas

A goal with spots is positioned at one spot, distances measured to each area's edge (Compat.GetPlayerWorldPosition). Camps: the spot nearest the player; a different spot takes over only once it is 40 yd (Spots.SWITCH) nearer than the current one, so the waypoint never flaps between two camps and never moves while the player is inside the current area. Area objectives (spread): a spot counts as visited once the player has been within its arrival radius (r past its edge); the current spot stays while the player is within r + 4 yd of it, then the nearest spot not visited in the last 300 s (Spots.FORGET) takes over (the one the player stands in counts as not visited; all visited: the one visited longest ago), so the spots form a circuit. Spots with alt are never picked by these rules; they are reached by hand and, once picked, hold like any current spot (camps: until another spot is 40 yd nearer). Engine.CycleSpot(stepIndex, goalIndex[, index]) moves on by hand to the nearest spot not visited lately, alternatives included (the current spot counts as visited), or to spot index; a spot picked that way holds until the player reaches it. It returns the new spot's index, or nil. Without a player position the first spot is used. The goto-arrival poll (0.5 s) re-picks the spot while the waypoint's goal has spots; a change re-evaluates (STEP_UPDATED, WAYPOINT_CHANGED). The goal view's map/x/y follow the chosen spot and it carries spots (count) and spread; the waypoint carries area (the spot's radius in yards) next to r, and with several spots spots (the goal's list) and spot (the current index), plus search = true for an area objective. The guide window's "Other" button cycles spots (CycleSpot) when the goal has no candidates; an area objective's subline reads "<mobs> all over this area: N spots". Cycling a goal of the current step (spots or candidates) aims the arrow at that goal (AimGoal), whichever goal it pointed at before, and the window releases any point picked by hand (Nav.ReleaseTarget), so the arrow moves to the chosen spot at once.

Race names

"Scourge" and "Undead" are the same race everywhere a race is compared (Forever's client name is unconfirmed).

Context-aware Suggest

Guides.Suggestions() orders the leveling guides that fit faction/race/class and level: the one whose zone is the player's current zone first, then the nearest by travel; ties keep the race's own chain. Suggest() is its first. The hub's Home lists all of them under "Suggested for you", open ones included (marked with their tab), then an open guide's follow-up once the player is within two levels of it.

Out-levelled guides

When the player's level exceeds the active guide's maxLevel by 2 or more, the Engine fires GUIDE_OUTLEVELED(guideId, suggestedId) once per guide per session, once the guide is placed (a catch-up may pass it whole first); the UI offers the jump. Setting behavior.skipGrey (default off; leveling guides only, see "Every step") auto-skips accept goals for quests already grey for the character (and turnin ... direct goals, which the compiler gives their quest's level too), and with them the quests' turn-ins and objectives, unless the quest is a prerequisite (pre, preAll) of a quest later in the guide or its chain that is not grey, directly or through other grey ones.

Class-training reminder

Class trainers ship in the core: Core/ClassTrainers.lua (generated by node tools/polestar.js trainers build, one trainer per town and class, with the Era frames of their maps) calls Polestar.RegisterClassTrainers({ { npc, npcName, map, x, y, f, faction, classes }, ... }), then Polestar.RegisterClassTrainerIds({ WARRIOR = { npcId, ... }, ... }) with every class trainer in trainers.json, not only one per town. Engine.NearestClassTrainer() returns the best one for the character (same ranking as candidate goals) or nil; Engine.IsClassTrainer(npc) whether an npc is any class trainer of the character's class, from the full id list (QoL learns spells only there). Opening the trainer window of one of the character's class stores CharState().classTrainLevel = level (and classTrainVisit, which a first sight never sets) and fires CLASS_TRAINED(level); a character first seen without a stored level counts as trained at that level. With behavior.trainReminder "2" (default; even levels only), "1" or "off", the Engine fires TRAIN_DUE(level, trainer) once per level when level - classTrainLevel >= N, unless the active tab's current step or its next applicable steps (4 in all) hold an applicable class train goal (an npc in the registry or train class). The UI shows a slim banner with Show (temporary Nav.SetTarget) and Later, cleared by CLASS_TRAINED; Options → Guide → Automation → "Trainer reminders".

Conditions checked during play

A step with when (format 4; GUIDE_FORMAT, "Conditions checked during play") applies only while every predicate holds (Own.whenFails, Own.predicateHolds): the level, quests in the log, ready to turn in, handed in or available (an accept's own test: level, pre/preAll through missingPre, closedBy), an item count in the bags, the zone or subzone text, the bind location (place names as the area's name in the client's language, Compat.GetAreaName, else as written). stepApplies reads them, so such a step counts as one for other characters while they fail, and a turn-in whose only accept sits there goes with it (orphaned). When the route moves past a step (latchPassed), its outcome is stored per character (Own.latchWhen): a when that held in CharState().when[guideId][step], one that failed as a skip in CharState().skipped, read from then on (Own.whenHolds): a step passed because its condition failed never comes back when the condition changes, one that applied stays applied, and Unskip brings a wrongly stored step back (storing it as held). Nothing is stored before QUEST_LOG_UPDATE (logReady; the log lags PLAYER_LOGIN): Own.latchCaughtUp stores the steps passed meanwhile once it can. ResetProgress forgets them; a pack update moves them with the steps (Own.remap). The live state they read already re-evaluates the guide (quest log, BAG_UPDATE_DELAYED, zone changes, PLAYER_LEVEL_UP, PLAYER_XP_UPDATE, HEARTHSTONE_BOUND). A level predicate with frac (when level>=12.5) compares the level plus the share of it done (XP / XPMax) with n + frac.

Along the way

A step with along (format 4; GUIDE_FORMAT, "Along the way") counts as done (stepDone), so the route passes over it and latches it like any passed step (a when on it is stored then). While the live step is past it, it applies and was not skipped, Own.alongListed holds and Engine.GetAlong lists its open goals (status active), until they are done or, with along = "<label>", until the live step reaches the step labelled so (Own.marksOf: labels and along steps per guide). Their statuses are in the step signature, so progress fires STEP_UPDATED. Engine.AimAlong sets aim to such a goal: findWaypoint leads there (waypointOf with the goal's own step) while it is listed and open, and a step change keeps it. The guide window lists them under "Then:" ("Along the way", fillAlong, at most six rows, not in compact mode); "Then:" and Up next skip along steps (GetStepView(i).along).

Unavailable quests

An accept whose closedBy names a quest that is in the log or done is unavailable before any visit (reason "closed", "No longer offered: you took <quest>"): a breadcrumb whose follow-up is taken, or one version of an exclusive chain when another was taken. An accept that lacks a prerequisite the route no longer brings is unavailable before any visit too (reason "pre", "Requires <quest> first"): one of preAll, or of pre when none of those is done, that is not done and that this guide does not still turn in (issue #70: Jorn Skyseer at the Crossroads needs The Angry Scytheclaws, the end of Sergra's chain in the previous Barrens guide). "Still turns in" is a turnin of that quest anywhere in the guide, before the accept or after it, that is not optional, applies to the character, is not in a skipped step, not skipped or ticked itself, and not unavailable in turn (followed down the chain of prerequisites, at most 8 deep): that prerequisite is the route's own business, so a chain the guide does later (or one lint warns about, taken before its prerequisite is handed in) keeps its accept, and its giver's window decides as below. So does a prerequisite whose goals in the guide are all for other characters (only): another race's or class's version of the chain, whose own the data does not list (the paladin charger's Emphasis on Sacrifice names the Human's Lord Grayson Shadowbreaker, not the Dwarf's). Prerequisites the data lacks (issue #51) mark nothing in advance; the giver's window covers them. The mark is live: it does not hold the step, the focus or the arrow, and it goes the moment the prerequisite is done (the guide then goes back to it), not on an unrelated turn-in. A giver's window that offers the quest (this session) beats the data: no mark in advance after that. Beyond that, when a gossip/greeting/detail window opens on an accept goal's NPC (any tab's current step) and the quest is neither offered, listed as active by that window (just accepted: the window refreshes before the quest log), in the log, nor completed, the Engine marks that accept unavailable for the session, with a reason: "level" (below minLevel), "pre" (a pre quest not done, title given) or "offer". A detail window says nothing about the NPC's other quests when it opens within 30 s of that NPC's gossip/greeting list, reward panel or turn-in (the quest picked from the list, or the follow-up the client pops after a turn-in), or when an item starts it. An unavailable accept and the goals of that quest in the guide (objectives, complete, turnin) count as skipped, so the guide moves on instead of stalling. The session marks clear on level-up and on any quest turn-in (either can open a quest whose prerequisite the data lacks). In steps a tab has moved past, a "level" or "pre" mark clears only once the data says the quest is open (level reached, prerequisites done: the guide goes back to it) and otherwise stays with its reason brought up to date; an "offer" mark stays (#161), keeping the step passed, so the guide never goes back to a giver who said no for no reason the data knows. Every mark goes for the quest itself when it is accepted, when its giver's window offers it (also from a later step: the guide then goes back to it, the player standing at that giver) or lists it as active. "offer" marks are saved at logout (not for a quest in the log) and restored at login at any level. The view carries unavailable = reason and unavailableText. When such marks move the guide past a step during play, the Engine fires STEP_SKIPPED; the guide window's banner then reads "Skipped <quest> · <reason>" (one line per quest) until the step changes again, it is dismissed, or 20 seconds pass. With the window hidden it goes to chat instead. Placement at load never fires it.
Missable quests (Core/Closes.lua, setting behavior.warnClosing, default off). Closes.For(q, title) lists what taking q closes for good for this character: the guide accepts whose closedBy names q, across every shown guide (indexed once, rebuilt on GUIDES_REGISTERED and PACKS_CHANGED), and, with the quest database loaded, the quests whose excl lists q (QuestDB.ClosedBy; excl is one-way for about one link in fifteen, so it is read from the closed quest's side). Left out: quests done or in the log, an accept whose guide is not for this character (Guides.GuideMatches: faction, race, class), the accept's only or the database's faction, race and class not matching, a quest one of its own closedBy/excl already closed, and other versions of q (the same title). On QUEST_DETAIL (not a quest the game accepted itself) the setting prints "Taking <quest> closes <quests> for good." and Automation leaves that accept to the player.

Not yet available

A Class guide (category = "Class") the character has not played (no step skipped, no goal ticked, none of its quests in the log or done) whose opening, the first step for this character with an accept or a level goal, waits on them (a level goal above their level, or accepts none of which they can take yet: the lowest minLevel, else a missing pre/preAll) holds at that step without placing: no waypoint, no unavailable marks moving it on, never complete. Engine.StartGate(id) gives the gate from the guide's data (no table kept by hand); the guide window shows "Available from level N" (or "Not available yet" and the quest to finish first) with who starts it, in place of the step, and the guide menu's row dims with "From level N". Next still looks at the steps. The gate is part of the step signature, so a level-up or turn-in that opens it re-renders the window and the guide starts by itself. Leveling and other guides are unchanged.

Farm spots

collect <item> xN nearest (no quest) compiles candidates of farm spots { map, x, y, f, faction, npcName, lvl = { min, max }, rate, spawns } (clustered item droppers; faction is zone safety; no npc). Among the spots the character's faction may use, the Engine drops those whose weakest mobs (lvl.min) are 3+ levels above the player, unless every spot is: then it keeps them all and ranks the smallest level gap first. Spots rank (after the continent tier, as for other candidates) by travel seconds plus farm seconds missing / (rate * killsPerMinute) * 60, where missing is count minus the items in the bags (at least 1) and killsPerMinute, from the middle mob level, is 1.0 for 1+ levels above, 1.5 for 0 to -4, 2.0 below that, 2.5 for grey, scaled by min(1, spawns / 8) (missing rate = 1, missing spawns = 8). A goal with a usable spot always has a target; the view carries the chosen spot's map, x, y, f, npcName, lvl and alternatives, and CycleCandidate walks the ranking.

Focus: one person at a time#

A step's goals form groups, walked in authored order: a goal joins the current group when it has the same "who", else starts a new one. "Who" is the goal's NPC (npc, or the chosen candidate's for smart goals) or object (object) for accept, turnin, talk, train, vendor, sethome, getfp, learn, buy and interact goals; every other goal (goto, kill, collect, use, complete, explore, level, skill, craft, rep, achieve, note, and NPC verbs without an NPC) is "here". An interact for a quest objective (quest: totems, mounds, cages, braziers) is "here" too: it is field work beside the kills, not a visit, so it shows with them instead of waiting behind them; a plain interact (no quest) is still a visit. So a hub step reads person by person, and a field step (goto the camp, kill, collect) stays one group. A goal that does not apply to the character (only, grey-skipped, never-had) joins the group around it instead of starting one, so a class-only line between two goals at the same person never splits that person's group.

  • Done:a group is done when none of its applicable goals still blocks (done, na, skipped or optional). Focus is the first group that is not done; nil when the step is done.
  • View:GetStepView(i) adds groups = { { kind = "npc"|"object"|"here", npc, object, name, map, x, y, f, goals = { goalIndex, ... }, done, pending }, ... } (pending: blocking goals still open) and focus = groupIndex; each goal view carries group = groupIndex. name is the NPC's or object's name (nil for "here"); map/x/y/f its position (the first located goal of the group).
  • Current goal and waypoint:the view's current is the goal the window marks and the arrow leads to: in the focus group, the first open blocking goal, else the first open optional one (not skipped); a "Go to" naming the step (the group's header, an area) only when nothing else is open. The arrow goes to the goal the player chose in the current step (AimGoal, a cycled spot or candidate) while it is open, else the current goal's own place (its coordinates, its spawn area, the source of its item), else the header's, else the focus group's first open located goal (blocking, then optional, then notes), else a quest's current goal at the place of the focus group (a goto already reached) or of the nearest group before it for the same quest (the giver of a bare complete), never of a later group; nil on a done step (no focus). Reaching the place of any other goal of the step arrives at its header. A focus change fires STEP_UPDATED(step) and WAYPOINT_CHANGED like any other progress.
  • Out-of-order play still counts: finishing a later person's goals marks that group done; the focus stays on the first open group.

Route choice ("Choose your path")#

A guide whose next lists several guides compiles to next (the author's default, the first) plus nextChoices (all of them, in order); each option may carry pitch, pros, cons (see GUIDE_FORMAT). next <id> when ... adds nextWhen (format 4: option id -> predicates, as a step's when), and nextChoices then lists the options even when there is one.

lua
Polestar.Engine.GetRouteChoice(fromId) -> { from, fromTitle, recommended, chosen, joinsAt,
    options = { { id, title, zoneName, minLevel, maxLevel, pitch, pros, cons, seconds,
                  quests, steps, recommended, chosen, outleveled, open }, ... } } | nil
Polestar.Engine.ChooseRoute(fromId, toId) -> bool   -- remembers, loads, fires ROUTE_CHOSEN
Polestar.Engine.PendingRouteChoice() -> fromId | nil -- the active tab's finished guide waiting for a choice
Polestar.Engine.RouteChoiceFor(id) -> fromId | nil  -- the choice that offers guide `id` to this character
Polestar.Engine.RouteOffered(fromId, toId) -> bool  -- fromId's `next` names toId and its `when`, if any, holds now
Polestar.Engine.NextGuide(fromId) -> id | nil       -- where the hand-off goes now; nil: none offered, or the player chooses
Polestar.Engine.OfferRouteChoice() -> fromId | nil  -- /polestar path: fires ROUTE_CHOICE
Polestar.Engine.GuideFocus(id) -> x, y | nil       -- live-frame percent of where the guide is for this character, for the
                                                    -- guide menu's zone art: open in a tab and unfinished, the current
                                                    -- step's first goal on the guide's zone map (`zone`); else (or when
                                                    -- that step is elsewhere) its first goal on it: a route option's focusX/Y
  • Options are the nextChoices that fit the character (faction, race, class) and are offered now: an option with nextWhen only while its predicates hold (Own.optionHolds, Own.predicateHolds on the live state, checked each time the options are listed: routeOptions, ChooseRoute, RouteOffered, so Guides.OfferedBy, the guide menu's "Next" line and its suggested follow-ups, and "Up next" on a finished guide); nothing is stored. outleveled when the level is at or past the option's maxLevel. seconds is TravelCost from the player to the option's first located goal; quests counts the accepts this character gets there; joinsAt names the guide every option continues to, when they share one.
  • Recommended:not outlevelled, then the quickest to reach (1-minute bands), then the author's order.
  • At completion (with behavior.autoNextGuide, and the guide either auto-loaded or seen incomplete): the remembered choice (CharState().routes[fromId]), else the only option offered, else with behavior.routeChoice = "recommended" the recommended one. With none offered (every option's when fails) the guide ends there, as without next. Otherwise the finished guide stays in its tab and ROUTE_CHOICE(fromId) fires once, after the evaluation.
  • Fresh start:with no saved tabs, when the suggested guide is an option of a choice the character fits, and the character is at most one level into that guide's range, login also fires ROUTE_CHOICE (the suggested route stays loaded meanwhile).
  • ChooseRoute loads the option in place of the finished guide, else of an open option of the same choice (changing route), else in the active tab.
  • Never-had quests:a turn-in, complete or objective goal for a quest the character neither has nor has done, and that its guide does not accept (for this character) at or before that step, does not apply (status na): it was picked up on another route, or skipped. chainNeeds follows every route option but one whose when can no longer hold (Own.optionClosed: a level<N or level<=N the character is past, a !turnedin quest handed in); the chain is built again once a kept conditional option closes. The hand-in plan (Core/RoutePlan.lua) treats a guide with a conditional option like a choice not made yet, unless the remembered choice is an unconditional option.
  • UI (UI/RouteChoice.lua, frame PolestarRouteChoice): one card per option. Each card opens on the zone's art (UI.ZoneArt, below) framed on focusX/focusY (where the route starts), lightly desaturated, fading into the card with the zone's name on it. Polestar ships no zone art; without the API or art for the map, a stretch of the night band stands in. Straight under the art, at the same y on every card, the stat row: levels (in text), travel time and quest count. Then the state chip, the pitch, at most 3 pros and 2 cons one line each (the full lists in the card's tooltip), on a grid every card shares so rules and lists line up; the recommended card has the accent ring and the primary button; "From now on, take the recommended zone" sets behavior.routeChoice; "Decide later" and the header ✕ close. It waits out combat. The guide window's completed state and the out-levelled banner open it when a choice applies; Options → Guide → Automation → "Where to level next". POLESTAR_CLIENT_ART=<folder> lets preview draw real map art from a local cache of decoded client tiles (base and overlay tiles, maps.json with each map's artID); those renders go to <folder>/preview/, never into docs/.

Tabs (several guides at once)#

lua
Polestar.Engine.GetTabs() -> { { guideId, title, category, stepIndex, stepCount, done, total, complete }, ... }, activeIndex
Polestar.Engine.OpenTab(guideId [, activate])  -> index   -- already open: just activates it (when activate ~= false)
Polestar.Engine.CloseTab(index)                            -- the last tab may be closed (no guide)
Polestar.Engine.SelectTab(index)
Polestar.Engine.MoveTab(from, to)

Up to 6 tabs per character, persisted as CharState().tabs = { guideId, ... } and CharState().activeTab. Every single-guide call above (GetGuide, SetGuide, GetStepIndex, GetStepView, Next, ..., GetWaypoint, GetProgress) acts on the active tab; SetGuide(id) replaces the active tab's guide (opening a first tab when none exist). Each tab keeps its own step state; per-guide manual and skip state stays keyed by guide id. Selecting a tab fires GUIDE_CHANGED (and STEP_CHANGED, WAYPOINT_CHANGED) for the newly active guide; any tab change fires TABS_CHANGED(tabs, activeIndex). The waypoint follows the active tab only. Automation considers the current and next two steps of every open tab. autoNextGuide replaces the finished guide within its own tab. On login, saved tabs reopen; Guides.Suggest() opens a first tab only when none survive (SavedVariables can come back nil). Each tab in the guide window reads Title (min–max) like the menu's rows; a narrow tab cuts the title, never the range (DESIGN, Guide-window tabs).

Read the rest of this section2154 words

Absent guides keep their place. A saved tab whose guide is not registered (its pack missing) or hidden (its pack switched off) is absent: it is not opened, but it stays in CharState().tabs after the open ones, with its place in CharState().absentAt = { [guideId] = place }, and its saved view (pins) is kept. At login and on PACKS_CHANGED, an absent guide that is shown again reopens at its old place, up to the tab limit; on PACKS_CHANGED, open tabs whose guide is gone close together and become absent (the active one hands over to its right neighbour, as CloseTab). Check-offs are keyed by guide id and survive regardless.

Step view (render-ready; the UI never re-derives status):

lua
{
  index = 3, title = "Kobold Camp", note = "...",
  status = "current" | "done" | "skipped" | "upcoming" | "na",
  goals = {
    {
      index = 1, t = "kill",
      status = "done" | "active" | "pending" | "na",
      text = "Kill Kobold Vermin",        -- plain default rendering, no color codes
      verb = "Kill", subject = "Kobold Vermin",
      subjectKind = "quest" | "npc" | "item" | "place" | "object" | "level" | "text",
      detail = "for Kobold Camp Cleanup",  -- or nil
      progress = { cur = 3, max = 10 } | nil,
      manual = true | false,               -- UI shows a checkbox when true
      optional = false, note = nil,
      map = 1429, x = 47.6, y = 35.1, f = nil,   -- when the goal has coordinates
      quest = 7, npc = 6, item = nil,
      -- A collect's compiled source (GUIDE_FORMAT "What the compiler fills
      -- in"): enemy = an NPC not of the character's faction.
      source = { name, kind = "npc" | "object", rank, enemy } | nil,
      -- The compiled `group` (group content), shown as the goal's group line.
      party = { kind = "elite" | "dungeon" | "raid" | "group", rank, name, size } | nil,
      -- The reading flow (below): starterOf, starter, folded, header.
    },
  },
}

Current step = first step, in order, that is applicable (only matches and, with when, its predicates hold), not skipped, and not done. A step is done when every applicable, non-optional goal is done; a step of only notes/optional goals is done at once, and so is an along step. Step views for indices before the current step report done, skipped or na; after it, upcoming or na. A view carries the step's along and label.

The reading flow shows one line per thing to do; the view flags what folds, statuses and verbs unchanged (the Engine and every other reader still see each goal):

  • A quest started by an item (an accept with an item and no giver, after a collect of that item outside a quest objective, in the same group) is one line, the quest. Until the item is in the bags the collect stands for it: starterOf = <accept's index> and starter = { item, source, note }; the accept is folded. The window words the collect's row as the accept (UI.ReadingGoal) and keeps its index, waypoint and spots. Then (item held, quest taken or out of reach) the collect is folded and the accept carries starter (held = true until taken). The line under it: "Runed Scroll: kill Aean Swiftriver (rare elite)", then "Runed Scroll: in your bags, right-click it".
  • A "Go to" naming the step's own place in a field group is header = true: the group's header already says it, so the window shows no row for it while the group has others, and the header carries the arrow's mark while it is the target.

Placement: when a tab loads a guide (login and reload, SetGuide/OpenTab, auto-chaining to next, /polestar reset), the evaluations until the quest log is readable (the first QUEST_LOG_UPDATE of the session), at most 10 s, and ended early by the player's own Next/Prev/Skip/Goto or check-off, place it from the quest log (a Class guide not yet available is not placed: it holds at its opening, see "Not yet available"):

  • Start = the first open step with work the player has started (an open goal of a quest in the log: objectives in progress, ready to turn in) or can do now (an accept their level and prerequisites allow). Skipped steps are never the start, even with their quest in the log, and ticked or skipped goals are no open work: a reload never pulls the player back to what they chose to pass. (Before this rule, a saved skip on a step with its quest in the log was dropped at every load, so a player who had skipped ahead with › was sent back to the first such step on every reload.)
  • Before the start (with no start: before the last quest progress), a step passes when every open goal is an accept the character cannot take yet (level, prerequisite; marked unavailable for the session, so it comes back once the level is reached or the prerequisite done), a soft goal (goto, fly, getfp, train, vendor, hearth, sethome, talk, non-quest kill/collect/buy) in a step before the last step with live quest progress (a done quest goal or an objective with some progress; class or race steps and goals, and optional goals, are no evidence), or a travel goal (goto, fly, hearth, explore) while the player stands within 150 yd of the start step's work. Soft goals passed are latched. Any other open goal holds the step.

The catch-up (Own.catchUp, with behavior.skipGrey on, in a guide that does not do every step): before the start is worked out, the accepts behind the last quest progress that are more than 2 levels below the character (green or lower) and not taken yet are left out, unless a quest worth doing needs them first (Own.needsFrom down pre/preAll, over the guide and the guides after it: Own.chainOf). Worth doing: an accept that is not grey, behind the anchor only from 2 levels below up. A quest in the log goes by the same rules ("log"): left out while not done, kept once it only has to be handed in. Left out (Own.leftOut[guideId], for the session; each placement works it out again), an accept no longer applies, with its turn-in and objectives; taking the quest by hand brings it back. In a guide the character is past (maxLevel + 2, as the out-levelled banner counts) going back is a detour: every grey or green accept not taken yet is left out, anywhere in the guide, unless at least Own.CHAIN_WORTH (3) accepts from yellow up need it down the chain (Own.catchUpPast); a quest in the log too, until it only has to be handed in. Grey accepts anywhere go by behavior.skipGrey's own rule (greyNeed). Travel goals (goto, fly, hearth, explore) before the start also pass in a step before the last left-out or grey-skipped accept: the way to what was left out goes with it. A flight path, a hearth, training or a purchase there still holds unless it is behind the last quest progress: the character may never have been there. A guide the character is past (an accept left out or grey-skipped, or maxLevel reached) with no start, no accept waiting for a level (a missing prerequisite is often a grey quest it left out, which never comes) and the quest log read passes whole (Own.passedWhole), its travel too. Only a guide just opened for the player (Own.opened with autoChain: the login's pick, Start guide, a guide chained to) then chains to next as a finished one does (Own.placedThrough); its flight paths, hearths and training ahead of the last quest progress pass for the session only (Own.passedNow), never stored as done. A guide the player opens by hand, or a login or /reload brings back, stays where placement puts it: at such a step, or finished. With every quest of a guide done (Own.allQuestsDone) all its soft goals pass and are latched. Start guide (Engine.StartGuide: the guide window with no guide, the welcome) opens StartingGuide() for the player (OpenTab(id, true, true), autoChain), so it goes on through such guides as the login's pick does. The next guide is placed the same way, even when it finished earlier in the session (another try): only a guide passed on the same run stops it (Own.chainSeen), against a loop of next. The picker's "done" check (GetGuideState) for a guide placement passed whole needs every quest the character can take in it turned in, so a guide passed with grey quests never done is not checked; a guide finished in play is done as before. Where it offers a route choice, the catch-up takes the option with the most quest work done (Own.likeliestRoute, Own.doneIn), the recommended one among equals. With no tab open at login (behavior.autoSelectGuide), a character who did quests in their race's first guide (Guides.FirstGuide(): the suggestion at level

  1. starts there and goes through the guides to where they are; one with nothing done there gets Guides.Suggest(), as before. GUIDE_PLACED(guideId, step, passed) fires for the active tab once placement of a guide just opened ends, with the guides passed whole.

Every step (Own.everyStep): any guide but a leveling one (category Leveling or none) does every step: no grey quest is left out and nothing is caught up (an item, a mount, a reputation, a profession, a class chain is done whole).

Ticks and stored check-offs keep counting. After placement soft steps hold as usual, so a quest taken out of order during play never jumps a training stop. A turn-in or objective of a quest whose accept sits in a skipped step (and that the player neither has nor has done) no longer applies. Quests their giver did not offer stay marked across a reload (offers, while the character is still that level), so the guide does not return to that giver. A pack update that changes a guide's shape matches the old and new steps by stepKeys (longest common run of equal step hashes) and moves ticks, skips and the saved view with them; saves from before step keys existed lose them.

Navigation: Next, Prev and Goto only change the step on screen (the pin); Skip is the only way to skip a step (saved, undone by Unskip; Goto to a skipped step un-skips it). The pin is saved (pins), so a reload shows the step the player was looking at; it is released at the live step, and while the step it shows gets done it follows forward (behind the live step up to it, ahead of it to the next open step, latching the step's transient goals). Prev and Next walk the same applicable steps, done, skipped or open. NPC interactions count for the live step too while another step is shown.

Waypoint:

lua
{ map = 1429, x = 47.6, y = 35.1, f = nil, label = "Kill Kobold Vermin", stepIndex = 3, goalIndex = 2, kind = "kill", r = 8 }

= the goal of the current step the player aimed at (AimGoal) while it is open; else the current goal (the focus group's first open blocking goal, else its first open optional one, else its header); else the focus group's first open goal with coordinates (blocking, then optional, then notes). A done step has none. Re-evaluation is event-driven and coalesced (≤ 10 per second); goto and explore arrival is polled only while some tab's current step has one open, or while the waypoint's goal has spots.

Automation (setting behavior.autoAccept, behavior.autoTurnin, default on; holding Shift suppresses): accept only quests that are accept goals, and turn in only quests that are turnin goals, in the current or next two steps (a direct one is picked from the gossip or greeting list of offered quests, and only while its goal is not done: the giver of a repeatable keeps offering it); never choose among multiple rewards; with behavior.warnClosing on, never accept a quest that closes another for good (Missable quests).

After its own accept or turn-in, the game often opens the NPC's next window by itself: the follow-up of the quest just handed in, or the NPC's other quests (#360). With behavior.closeSkipped (default on) Automation closes that window (CloseQuest, C_GossipInfo.CloseGossip) on the next frame when it shows nothing the guide takes: no accept goal not yet taken, no quest in the log with a turnin goal, no direct goal, in the current or next two steps, whatever the automation settings. Only the first quest or gossip window at the same NPC within 2 seconds of Polestar's accept or turn-in counts; a later window, one at another NPC or one the player opens from a list stays. Never in combat, with Shift held, on a progress or reward window, or on a gossip window with options of its own (a vendor, trainer or innkeeper). A quest the guide still wants at that NPC is taken on the next talk.

Was this page helpful?

Start with, or act

  • PlayersInstall and first run
  • PlayersCommunity guides
  • PlayersFAQ
  • Guide authorsWriting a community guide
  • Guide authorsGuide format cheatsheet
  • ContributorsContributing
  • ActionCopy the AddOns folder (Windows)C:\Program Files (x86)\World of Warcraft\_classic_beta_\Interface\AddOns
  • ActionCopy the AddOns folder (macOS)/Applications/World of Warcraft/_classic_beta_/Interface/AddOns
  • ActionFind an optionEvery setting with its default; type its name here to jump to it.
  • ActionEvery slash command