Core
Contributor63 min readUpdated
Lifecycle, the message bus, settings, commands, localisation, the compiled guide data, the guide registry and the engine.
On this page
- Lifecycle
- Message bus
- Settings
- Commands
- Caught errors and the Polestar Discord
- Frames under the game's UI
- Gamepad interface
- Localisation
- Names
- Guide texts
- Compat — the client's game data goes through here
- Map frames
- Core services: Data, Hooks, Search
- Guide data (compiled Lua) — the toolchain/Engine contract
- The guide registry (Core/Guides.lua)
- Engine
- Smart logic
- Focus: one person at a time
- Route choice ("Choose your path")
- 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#
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 guideOne route, written as a .guide file and compiled to Lua; its id reads like forever/alliance/human/01-elwynn-forest. progress from saved state alone: the current stepOne stop in a guide: a title, optional conditions and tip, and at least one goal. 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#
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:
| Message | Args | Fired by |
|---|---|---|
GUIDES_REGISTERED | — | Core, once at PLAYER_LOGIN after packs registered (and installed packs scanned) |
PACKS_CHANGED | packId | Guides.SetPackShown: a packAn addon of guides for Polestar, built by the compiler from a folder with a pack.json (the folder is a pack too); Polestar's own is Polestar_Guides (pack id guides).'s guides were hidden or shown |
GUIDE_CHANGED | guideId or nil | Engine |
STEP_CHANGED | stepIndex | Engine |
STEP_UPDATED | stepIndex | Engine (goalOne thing to do in a step, as a verb and its arguments (accept 783, kill, goto, turnin...). 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_LEARNED | kind ("npc" | "object"), id | Names, when a name learned in play is new or changed (the CodexLookup's database of items, mounts, pets, recipes, NPCs, rares, quests, zones, dungeons and treasures, with where each comes from (codex build, the Polestar_Codex addon)., Dungeons and ItemDBItem stats for the gear advice (itemdb build and gear build, the Polestar_ItemDB addon). caches drop their copy) |
STEP_SKIPPED | fromStep, 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_STEP | guideId, stepIndex | Engine, after Skip() (the window, the menu, /polestar skip): the guide windowThe small window that shows the current step and its goals while playing (UI/GuideFrame.lua). offers the undo |
WAYPOINT_CHANGED | waypoint or nil | Engine (and Nav when an override is set/cleared) |
GUIDE_COMPLETED | guideId | Engine |
GUIDE_PLACED | guideId, 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_UP | true | false | Guide 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_CHANGED | path, value | Core settings |
TOGGLE_FRAME / SHOW_FRAME / HIDE_FRAME | — | commands, keybinds → UI |
SHOW_PICKER | — | commands → UI |
SHOW_OPTIONS | section or nil | commands → UI |
SHOW_WELCOME | page or nil | About's "Show the welcome again", /polestar welcome → UI/Welcome.lua |
SHOW_COPY | title, 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 hubPolestar's main window: a rail of sections (Guides, Lookup, Dungeons, Rares) with Options at its foot (UI/Picker.lua). 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#
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#
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#
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 inviteEvery 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#
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#
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 mouseWith 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 RecorderThe optional Polestar_Recorder addon that records play; record import turns its sessions into data/recordings. page callsPolestarRecorder_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:
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 kindOrder
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
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
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
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
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.
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 bycompilefrom the pack'slocales/<locale>/files): only that one loads, only on that client, throughData.Pack, at the pack's ownADDON_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 itsADDON_LOADEDCore/Guides.lualoads 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 onPLAYER_REGEN_ENABLED; a names pack landing late (NAMES_LOADED) refreshes every guide; a refresh after login firesGUIDES_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.CheckInstallchecks 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.
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 }, ... } } | nilMap 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:
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.
Core services: Data, Hooks, Search#
Shared by every module (summarised in docs/SUITE.md, "Runtime services");
each file's header lists its full API.
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, reasonHooks 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.
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 menuThe hub's Guides section, where a player picks a guide.'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".
t | Required | Optional | Done when (Engine) |
|---|---|---|---|
accept | quest, title | npc/npcName, object/objName, item/itemName, minLevel, qlvl, pre, preAll, closedBy, preTitles | on quest or completed |
turnin | quest, title | npc/npcName, object/objName, rewards = { choices = { { item, itemName, quality } }, items = { ... }, xp, money } (copper), direct | completed; 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 |
complete | quest, title | — | ready for turn-in or completed |
kill | target | npc, count, quest, oi | its 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 |
collect | item, itemName | count, quest, oi | objective 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) |
goto | map, x, y | text, 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) |
talk | npc, npcName | — | gossip/quest frame opened with that NPC during this step; else manual |
interact | object, objName | quest, oi | objective state if quest (the line named after the object, else oi); else manual |
use | item, itemName | quest, oi | objective state if quest; else manual |
sethome | location | npc, npcName, area | bound 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 |
hearth | location | area | zone or subzone equals the client's name of area (without one, location), else manual; latched once it lands in its live step |
getfp | location | npc, npcName, node, area | with 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 |
fly | location | area, node | as 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, text | trainer window opened during this step, else manual |
buy | item, itemName | count, npc, npcName | item count ≥ count (default 1) |
vendor | — | npc, npcName, text | merchant window opened during this step, else manual |
level | level | frac | player level > level, or = level with XP / XPMax ≥ frac (no frac: level ≥ level) |
note | text | — | never blocks (informational) |
skill | skill (name), level | skillLine | skill rank ≥ level |
learn | spell, spellName | skillLine, npc/npcName (trainer), item (recipe item) | spell known |
craft | spell, spellName | item/itemName (created), count, untilSkill, skillLine, reagents = { { item, itemName, count } } | skill ≥ untilSkill if set, else created-item count ≥ count, else manual |
rep | faction, factionName, standing (1–8) | — | standing ≥ standing |
explore | map, x, y | text (area name) | area at that point explored; else arrival (latched) |
achieve | achievement, achievementName | criteria (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) andGuides.OwnsId(which prefix a pack owns:forever/anddraft/forguides,lookup/for the internallookuppack, registered withinternal = trueand never listed or switchable, every<author>/that is a valid, unreserved pack id forimported(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 asguide-refusedproblems when it comes. - Format:
Guides.FORMATS(the formats this Polestar reads,1to4);Guides.Validatechecks each guide against theREQUIREDverb table (Guides.VERBS). - Problems: each refusal goes on the pack's record,
problems = { { kind = "pack-refused"|"guide-refused"|"no-guides", guide?, message } }, and tons.Debug. - Literal text:
ns.EscapeText(|doubled), ids excepted. - Packs on disk:
Guides.ScanInstalled, at login beforeGUIDES_REGISTERED, gives every addon whose TOC hasX-Polestar-Packand 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.
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 elseImported 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).
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#
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, totalStepsSmart logic#
Candidate goals
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
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
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
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
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
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
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.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
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)addsgroups = { { kind = "npc"|"object"|"here", npc, object, name, map, x, y, f, goals = { goalIndex, ... }, done, pending }, ... }(pending: blocking goals still open) andfocus = groupIndex; each goal view carriesgroup = groupIndex.nameis 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
currentis 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 barecomplete), 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 firesSTEP_UPDATED(step)andWAYPOINT_CHANGEDlike 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.
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
nextChoicesthat fit the character (faction, race, class) and are offered now: an option withnextWhenonly while its predicates hold (Own.optionHolds,Own.predicateHoldson the live state, checked each time the options are listed:routeOptions,ChooseRoute,RouteOffered, soGuides.OfferedBy, the guide menu's "Next" line and its suggested follow-ups, and "Up next" on a finished guide); nothing is stored.outleveledwhen the level is at or past the option's maxLevel.secondsisTravelCostfrom the player to the option's first located goal;questscounts the accepts this character gets there;joinsAtnames 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 withbehavior.routeChoice = "recommended"the recommended one. With none offered (every option'swhenfails) the guide ends there, as withoutnext. Otherwise the finished guide stays in its tab andROUTE_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.chainNeedsfollows every route option but one whosewhencan no longer hold (Own.optionClosed: alevel<Norlevel<=Nthe character is past, a!turnedinquest 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, framePolestarRouteChoice): one card per option. Each card opens on the zone's art (UI.ZoneArt, below) framed onfocusX/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" setsbehavior.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>letspreviewdraw real map art from a local cache of decoded client tiles (base and overlay tiles,maps.jsonwith each map'sartID); those renders go to<folder>/preview/, never intodocs/.
Tabs (several guides at once)#
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):
{
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
acceptwith anitemand no giver, after acollectof 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>andstarter = { item, source, note }; the accept isfolded. 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 isfoldedand the accept carriesstarter(held = trueuntil 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
- 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:
{ 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.