# Core

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

Page: https://polestar.guide/docs/contributors/architecture/core (Polestar docs > Contributors > Architecture)

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

## Lifecycle

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

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

## Message bus

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

Messages:

| Message | Args | Fired by |
|---|---|---|
| `GUIDES_REGISTERED` | — | Core, once at PLAYER_LOGIN after packs registered (and installed packs scanned) |
| `PACKS_CHANGED` | packId | `Guides.SetPackShown`: a pack's guides were hidden or shown |
| `GUIDE_CHANGED` | guideId or nil | Engine |
| `STEP_CHANGED` | stepIndex | Engine |
| `STEP_UPDATED` | stepIndex | Engine (goal status/progress changed, same step); Names (names arrived) |
| `NAMES_LOADED` | — | Names, when the names pack loads after a refusal in combat (the quest tracker reads its names again) |
| `NAME_LEARNED` | kind ("npc" \| "object"), id | Names, when a name learned in play is new or changed (the Codex, Dungeons and ItemDB 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 window 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 hub at Import |
| `TOGGLE_PICKER` / `TOGGLE_OPTIONS` | — | key bindings and gamepad chords (`POLESTAR_MENU`, `POLESTAR_OPTIONS`) → UI: open, or close when open |
| `TOGGLE_FOCUS` | — | `POLESTAR_FOCUS` → UI/GamepadGuide.lua (focus mode) |

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

## Settings

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

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

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

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

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

## Commands

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

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

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

## Caught errors and the Polestar Discord

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

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

## Frames under the game's UI

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

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

## Gamepad interface

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

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

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

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

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")`.

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 kind
```

- **Order.** The client's own name first, where the client has an API by id:
  a quest's title (`Compat.GetQuestTitle`, `C_QuestLog.GetTitleForQuestID`),
  an item's name (`Compat.GetItemName`, `C_Item.GetItemNameByID`), a spell's
  (`C_Spell.GetSpellName`, `GetSpellInfo`), a faction's (`C_Reputation`,
  `GetFactionInfoByID`; a faction the character has not met falls to the
  pack) and an area's (`Compat.GetAreaName`). An instance is the pack's
  first (`map`, Map.csv), then `GetRealZoneText(mapID)` when that names
  somewhere other than the current zone (an older client ignores the
  argument). Flight points (`taxi`) and encounters are the pack's only.
  Then the names pack of the client's
  language, `Polestar_Names_<GetLocale()>`, loaded through `Data.Pack` on the
  first ask (refused in combat, it loads when combat ends, fires
  `NAMES_LOADED` and redraws the guide window once; a missing
  folder is final for the session and harmless). Then nil. On an English
  client every answer is nil and nothing loads.
- **Learned in play.** The client has no API for NPC or object names by id,
  but it names the units it shows: on `PLAYER_TARGET_CHANGED`,
  `UPDATE_MOUSEOVER_UNIT`, `NAME_PLATE_UNIT_ADDED` and the NPC windows
  (`GOSSIP_SHOW`, `QUEST_GREETING`, `QUEST_DETAIL`, `QUEST_PROGRESS`,
  `QUEST_COMPLETE`, `MERCHANT_SHOW`, `TRAINER_SHOW`: the `npc` unit, a
  quest-giving object's GUID too) `Names.Learn` keeps the unit's name by its
  GUID's id in `PolestarDB.names[<locale>].npc` / `.object` (account-wide,
  per client language, read through `ns.DB()` each time; player-controlled
  units are skipped; a name equal to the pack's is not kept; at most 2,000
  ids, `order` oldest first, the oldest tenth dropped past the cap).
  `Names.Npc` and `Names.Object` answer a learned name before the pack's. A
  new or changed one fires `NAME_LEARNED(kind, id)`, and redraws the guide
  window once when the current or next step names that id. The Codex's
  decoded records, the open dungeon page and ItemDB's parsed quests and
  sources drop their copies on `NAME_LEARNED` and on `NAMES_LOADED`.
- **Asking the server.** A quest or item the client has not cached is asked
  for once a session (`Compat.RequestQuest`, `Compat.RequestItem`); the
  pack's name, or the English, shows meanwhile. When asked names arrive
  (`QUEST_DATA_LOAD_RESULT`, `ITEM_DATA_LOAD_RESULT`), one `STEP_UPDATED`
  per burst refills the guide window's rows.
- **Consumers.** The guide window's labels (`Engine` `describe`, the quest
  title order: on a non-English client the client's title before the guide's
  English one; skill goals' professions through `L`, learn and craft goals'
  spells and rep goals' factions by id; the class trainer of
  `Engine.NearestClassTrainer`), the target button's `/targetexact`
  (`QoL/Buttons.lua`), the
  quest database's names (`QuestDB.GetQuestTitle`, `EntityName`, `GetNpc`,
  `GetItem`, `GetItemDroppers`, `GetObjectiveMobs`: tooltips and the target
  button's droppers), objective matching (`Quests/Tracker.lua`: a client line
  is matched on the translated name before falling back to order, and object
  tooltips find their objectives by the translated name), the Codex's
  records (`Codex.Item/Npc/Object/Quest`, an instance's bosses and
  `Codex.Name` for those kinds: so Lookup, its sources, the item-tooltip
  spotter and Rares read them translated), a dungeon page as it is decoded
  (`Dungeons/Dungeons.lua`: quest titles, start and finish points, reward and
  starting items, keys, bosses by encounter, NPC or object, attunement
  names), the gear advice's leveling data (`ItemDB.ItemName`, `Quest`,
  `Sources`), and `Search`: on a
  non-English client each Codex kind's index gets, once, a line per id whose
  translated name (or, for zones, the client's map name) differs, a match
  counts an id once and is listed under its translated name. The collect
  goals' compiled sources carry their ids (`fromId`, `fromAlsoId`, of kind
  `fromKind`), so the goal line names them in the client's language;
  `from` and `fromAlso` are the English for older packs and English clients.
- **The packs.** One load-on-demand addon per language
  (`addon/Polestar_Names_<locale>/`: `Names.lua` calling `Names.Add` per
  1,000 names and kind, the TOC, `LICENSE-DATA.txt`, Ding's `CREDITS.md`),
  committed and shipped. Kinds: `quest`, `npc`, `object`, `item`, and `map`
  (instance maps), `encounter` (DungeonEncounter ids), `faction`, `taxi`
  (TaxiNodes ids) and `objective` (the objective text of the dungeon quests
  whose objective is an event). `node tools/polestar.js i18n names` builds
  them, per id the first source with a name winning: the client's own
  tables in that language (the game-table export with `&locale=`: Forever's ItemSparse,
  Map, DungeonEncounter, Faction and TaxiNodes, then the Era client's
  ItemSparse for the items Forever's lacks; the newest build holding every
  one in English and each language, and none stops the build), the web database's
  Forever database in that language (read from `data/sources/<web-db>/names`;
  without that cache this source is empty; a bracketed name is no translation), the community names database (cloned into
  `data/sources/<names-db>`, pinned to a commit and its files' sha256s: `DING` in
  `tools/lib/i18n/names.js`), then the Era quest database's `db/<locale>` (no esMX). A name
  equal to the English one, from any source, is never stored: a translation
  from a later source wins, else the English shows as it would; a name
  marking unused content (`[PH]`, `[UNUSED]`, `(DNT)`, `<NYI>`, `(test)`
  and their translations) is none. Areas need no pack: the client names
  them by id (`Compat.GetAreaName`). Only the ids the compiled guides, the
  quest database, the Codex's data and the travel data name are kept.
  `--no-<quest-db>` leaves the community names database out. `Data.CheckInstall` checks the client's
  pack like the other optional data packs: never missing, named when its
  version differs.

## Guide texts

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

```
GuideTexts.Prepare(def, live) / GuideTexts.Refresh(def) -> changed
GuideTexts.Load(addon) -> "loaded" | "combat" | nil
GuideTexts.TextsAddon(addon) -> "<addon>_<locale>" | nil   -- nil on English
GuideTexts.PackText(packId, text) -> translation | text
Polestar.RegisterGuideTexts(locale, packId, { pack = {...}, guides = { [id] = {...} } })
```

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

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

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

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

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

### Map frames

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

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

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

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

## Core services: Data, Hooks, Search

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## Engine

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

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

### Smart logic

- **Candidate goals.** A compiled goal may carry `candidates = { { npc, npcName, map, x, y, z?, f, faction = "A"|"H"|"AH", races?, classes? }, ... }`
  (from `train <Profession>`, `train class`, `buy … nearest`, `vendor nearest`, `sethome nearest`; see GUIDE_FORMAT).
  The Engine filters by the character's faction (and races/classes when given),
  then picks the cheapest by `Polestar.Nav.EstimateTravel(from, to)` seconds when
  Nav provides it, else world distance on the same continent with a large penalty
  across continents. A candidate in a zone whose level range (`Maps.GetZoneLevels`)
  starts more than 10 levels above the character comes after every other
  choice, other continents included. It re-picks on step start, zone change and landing, never per
  frame. The goal view then carries the chosen `npc`, `npcName`, `map`, `x`, `y`,
  `f`, plus `alternatives = n`; `Polestar.Engine.CycleCandidate(stepIndex, goalIndex)`
  moves to the next-best one. Profession `train` goals are done when the
  profession's max rank reaches that rank's cap (75/150/225/300), so a trainer
  anywhere counts. `train class` is done on opening a trainer window for any
  candidate during the step, or when there is nothing new to learn: the
  character last visited its class trainer (`classTrainVisit`) at this level,
  or at the even level below an odd one, since class trainers teach on even
  levels only.
- **Spawn areas.** A goal with `spots` is positioned at one spot, distances
  measured to each area's edge (`Compat.GetPlayerWorldPosition`). Camps: the
  spot nearest the player; a different spot takes over only once it is 40 yd
  (`Spots.SWITCH`) nearer than the current one, so the waypoint never flaps
  between two camps and never moves while the player is inside the current
  area. Area objectives (`spread`): a spot counts as visited once the player
  has been within its arrival radius (`r` past its edge); the current spot
  stays while the player is within `r` + 4 yd of it, then the nearest spot not
  visited in the last 300 s (`Spots.FORGET`) takes over (the one the player
  stands in counts as not visited; all visited: the one visited longest ago),
  so the spots form a circuit. Spots with `alt` are never picked by these
  rules; they are reached by hand and, once picked, hold like any current spot
  (camps: until another spot is 40 yd nearer). `Engine.CycleSpot(stepIndex,
  goalIndex[, index])` moves on by hand to the nearest spot not visited lately,
  alternatives included (the current spot counts as visited), or to spot
  `index`; a spot picked that way holds until the player reaches it. It returns
  the new spot's index, or nil. Without a player position the first spot is
  used. The goto-arrival poll (0.5 s) re-picks the spot while the waypoint's
  goal has spots; a change re-evaluates (`STEP_UPDATED`, `WAYPOINT_CHANGED`). The goal
  view's `map`/`x`/`y` follow the chosen spot and it carries `spots` (count)
  and `spread`; the waypoint carries `area` (the spot's radius in yards) next
  to `r`, and with several spots `spots` (the goal's list) and `spot` (the
  current index), plus `search = true` for an area objective. The guide
  window's "Other" button cycles spots (CycleSpot) when the goal has no
  candidates; an area objective's subline reads "<mobs> all over this area: N spots".
  Cycling a goal of the current step (spots or candidates) aims the arrow at
  that goal (`AimGoal`), whichever goal it pointed at before, and the window
  releases any point picked by hand (`Nav.ReleaseTarget`), so the arrow moves
  to the chosen spot at once.
- **Race names.** `"Scourge"` and `"Undead"` are the same race everywhere a race is
  compared (Forever's client name is unconfirmed).
- **Context-aware Suggest.** `Guides.Suggestions()` orders the leveling guides
  that fit faction/race/class and level: the one whose zone is the player's
  current zone first, then the nearest by travel; ties keep the race's own
  chain. `Suggest()` is its first. The hub's Home lists all of them under
  "Suggested for you", open ones included (marked with their tab), then an
  open guide's follow-up once the player is within two levels of it.
- **Out-levelled guides.** When the player's level exceeds the active guide's
  `maxLevel` by 2 or more, the Engine fires `GUIDE_OUTLEVELED(guideId,
  suggestedId)` once per guide per session, once the guide is placed (a
  catch-up may pass it whole first); the UI offers the jump. Setting
  `behavior.skipGrey` (default off; leveling guides only, see "Every step")
  auto-skips `accept` goals for quests already grey for the character (and
  `turnin ... direct` goals, which the compiler gives their quest's level
  too), and with them the quests' turn-ins and objectives,
  unless the quest is a prerequisite (`pre`, `preAll`) of a quest later in the
  guide or its chain that is not grey, directly or through other grey ones.
- **Class-training reminder.** Class trainers ship in the core:
  `Core/ClassTrainers.lua` (generated by `node tools/polestar.js trainers build`,
  one trainer per town and class, with the Era frames of their maps) calls
  `Polestar.RegisterClassTrainers({ { npc, npcName, map, x, y, f, faction, classes }, ... })`,
  then `Polestar.RegisterClassTrainerIds({ WARRIOR = { npcId, ... }, ... })`
  with every class trainer in `trainers.json`, not only one per town.
  `Engine.NearestClassTrainer()` returns the best one for the character (same
  ranking as candidate goals) or nil; `Engine.IsClassTrainer(npc)` whether an
  npc is any class trainer of the character's class, from the full id list
  (QoL learns spells only there).
  Opening the trainer window of one of the character's class stores
  `CharState().classTrainLevel = level` (and `classTrainVisit`, which a first
  sight never sets) and fires
  `CLASS_TRAINED(level)`; a character first seen without a stored level counts
  as trained at that level. With `behavior.trainReminder` `"2"` (default; even
  levels only), `"1"` or `"off"`, the Engine fires `TRAIN_DUE(level, trainer)`
  once per level when `level - classTrainLevel >= N`, unless the active tab's
  current step or its next applicable steps (4 in all) hold an applicable class
  `train` goal (an npc in the registry or `train class`). The UI shows a slim
  banner with Show (temporary `Nav.SetTarget`) and Later, cleared by
  `CLASS_TRAINED`; Options → Guide → Automation → "Trainer reminders".
- **Conditions checked during play.** A step with `when` (format 4;
  GUIDE_FORMAT, "Conditions checked during play") applies only while every
  predicate holds (`Own.whenFails`, `Own.predicateHolds`): the level, quests
  in the log, ready to turn in, handed in or available (an accept's own
  test: level, `pre`/`preAll` through `missingPre`, `closedBy`), an item
  count in the bags, the zone or subzone text, the bind location (place
  names as the area's name in the client's language, `Compat.GetAreaName`,
  else as written). `stepApplies` reads them, so such a step counts as one
  for other characters while they fail, and a turn-in whose only accept sits
  there goes with it (`orphaned`). When the route moves past a step
  (`latchPassed`), its outcome is stored per character (`Own.latchWhen`): a
  `when` that held in `CharState().when[guideId][step]`, one that failed as
  a skip in `CharState().skipped`, read from then on (`Own.whenHolds`): a
  step passed because its condition failed never comes back when the
  condition changes, one that applied stays applied, and `Unskip` brings a
  wrongly stored step back (storing it as held). Nothing is stored before
  `QUEST_LOG_UPDATE` (`logReady`; the log lags `PLAYER_LOGIN`):
  `Own.latchCaughtUp` stores the steps passed meanwhile once it can.
  `ResetProgress` forgets them; a pack update moves them with the steps
  (`Own.remap`). The live state they read already re-evaluates the guide
  (quest log, `BAG_UPDATE_DELAYED`, zone changes, `PLAYER_LEVEL_UP`,
  `PLAYER_XP_UPDATE`, `HEARTHSTONE_BOUND`). A `level` predicate with `frac`
  (`when level>=12.5`) compares the level plus the share of it done
  (XP / XPMax) with `n + frac`.
- **Along the way.** A step with `along` (format 4; GUIDE_FORMAT, "Along
  the way") counts as done (`stepDone`), so the route passes over it and
  latches it like any passed step (a `when` on it is stored then). While the
  live step is past it, it applies and was not skipped, `Own.alongListed`
  holds and `Engine.GetAlong` lists its open goals (status `active`), until
  they are done or, with `along = "<label>"`, until the live step reaches
  the step labelled so (`Own.marksOf`: labels and along steps per guide).
  Their statuses are in the step signature, so progress fires
  `STEP_UPDATED`. `Engine.AimAlong` sets `aim` to such a goal: `findWaypoint`
  leads there (`waypointOf` with the goal's own step) while it is listed and
  open, and a step change keeps it. The guide window lists them under "Then:"
  ("Along the way", `fillAlong`, at most six rows, not in compact mode);
  "Then:" and Up next skip along steps (`GetStepView(i).along`).
- **Unavailable quests.** An `accept` whose `closedBy` names a quest that is
  in the log or done is unavailable before any visit (reason `"closed"`,
  "No longer offered: you took <quest>"): a breadcrumb whose follow-up is
  taken, or one version of an exclusive chain when another was taken. An
  `accept` that lacks a prerequisite the route no longer brings is
  unavailable before any visit too (reason `"pre"`, "Requires <quest>
  first"): one of `preAll`, or of `pre` when none of those is done, that is
  not done and that this guide does not still turn in (issue #70: Jorn
  Skyseer at the Crossroads needs The Angry Scytheclaws, the end of Sergra's
  chain in the previous Barrens guide). "Still turns in" is a `turnin` of
  that quest anywhere in the guide, before the accept or after it, that is
  not optional, applies to the character, is not in a skipped step, not
  skipped or ticked itself, and not unavailable in turn (followed down the
  chain of prerequisites, at most 8 deep): that prerequisite is the route's
  own business, so a chain the guide does later (or one lint warns about,
  taken before its prerequisite is handed in) keeps its accept, and its
  giver's window decides as below. So does a prerequisite whose goals in the
  guide are all for other characters (`only`): another race's or class's
  version of the chain, whose own the data does not list (the paladin
  charger's Emphasis on Sacrifice names the Human's Lord Grayson
  Shadowbreaker, not the Dwarf's). Prerequisites the data lacks (issue #51)
  mark nothing in advance; the giver's window covers them. The mark is live:
  it does not hold the step, the focus or the arrow, and it goes the moment
  the prerequisite is done (the guide then goes back to it), not
  on an unrelated turn-in. A giver's window that offers the quest (this
  session) beats the data: no mark in advance after that. Beyond
  that, when a gossip/greeting/detail window opens on an `accept` goal's NPC
  (any tab's current step) and the quest is neither offered, listed as active
  by that window (just accepted: the window refreshes before the quest log),
  in the log, nor
  completed, the Engine marks that accept unavailable for the session, with a
  reason: `"level"` (below minLevel), `"pre"` (a `pre` quest not done, title
  given) or `"offer"`. A detail window says nothing about the NPC's other
  quests when it opens within 30 s of that NPC's gossip/greeting list, reward
  panel or turn-in (the quest picked from the list, or the follow-up the
  client pops after a turn-in), or when an item starts it. An unavailable
  accept and the goals of that quest in the
  guide (objectives, complete, turnin) count as skipped, so the guide moves on
  instead of stalling. The session marks clear on level-up and on any quest
  turn-in (either can open a quest whose prerequisite the data lacks). In
  steps a tab has moved past, a `"level"` or `"pre"` mark clears only once
  the data says the quest is open (level reached, prerequisites done: the
  guide goes back to it) and otherwise stays with its reason brought up to
  date; an `"offer"` mark stays (#161), keeping the step passed, so the
  guide never goes back to a giver who said no for no reason the data
  knows. Every mark goes for the quest itself when it is accepted, when its
  giver's window offers it (also from a later step: the guide then goes
  back to it, the player standing at that giver) or lists it as active.
  `"offer"` marks are saved at logout (not for a quest in the log) and
  restored at login at any level. The view carries `unavailable = reason`
  and `unavailableText`.
  When such marks move the guide past a step during play, the Engine fires
  `STEP_SKIPPED`; the guide window's banner then reads "Skipped <quest>  ·
<reason>" (one line per quest) until the step changes again, it is
dismissed, or 20 seconds pass. With the window hidden it goes to chat
instead. Placement at load never fires it.
- **Missable quests** (`Core/Closes.lua`, setting `behavior.warnClosing`,
default off). `Closes.For(q, title)` lists what taking `q` closes for good
for this character: the guide accepts whose `closedBy` names `q`, across
every shown guide (indexed once, rebuilt on `GUIDES_REGISTERED` and
`PACKS_CHANGED`), and, with the quest database loaded, the quests whose
`excl` lists `q` (`QuestDB.ClosedBy`; `excl` is one-way for about one link
in fifteen, so it is read from the closed quest's side). Left out: quests
done or in the log, an accept whose guide is not for this character
(`Guides.GuideMatches`: faction, race, class), the accept's `only` or the
database's faction, race and class not matching, a quest one of its own
`closedBy`/`excl` already closed,
and other versions of `q` (the same title). On `QUEST_DETAIL` (not a quest
the game accepted itself) the setting prints "Taking <quest> closes <quests>
for good." and Automation leaves that accept to the player.
- **Not yet available.** A Class guide (`category = "Class"`) the character
has not played (no step skipped, no goal ticked, none of its quests in the
log or done) whose opening, the first step for this character with an
accept or a `level` goal, waits on them (a `level` goal above their level,
or accepts none of which they can take yet: the lowest `minLevel`, else a
missing `pre`/`preAll`) holds at that step without placing: no waypoint, no
unavailable marks moving it on, never complete. `Engine.StartGate(id)`
gives the gate from the guide's data (no table kept by hand); the guide
window shows "Available from level N" (or "Not available yet" and the
quest to finish first) with who starts it, in place of the step, and the
guide menu's row dims with "From level N". Next still looks at the steps.
The gate is part of the step signature, so a level-up or turn-in that opens
it re-renders the window and the guide starts by itself. Leveling and other
guides are unchanged.
- **Farm spots.** `collect <item> xN nearest` (no quest) compiles `candidates` of
farm spots `{ map, x, y, f, faction, npcName, lvl = { min, max }, rate, spawns }`
(clustered item droppers; `faction` is zone safety; no `npc`). Among the
spots the character's faction may use, the Engine drops those whose weakest
mobs (`lvl.min`) are 3+ levels above the player, unless every spot is: then
it keeps them all and ranks the smallest level gap first. Spots rank (after
the continent tier, as for other candidates) by travel seconds plus farm
seconds `missing / (rate * killsPerMinute) * 60`, where `missing` is `count`
minus the items in the bags (at least 1) and killsPerMinute, from the
middle mob level, is 1.0 for 1+ levels above, 1.5 for 0 to -4, 2.0 below
that, 2.5 for grey, scaled by `min(1, spawns / 8)` (missing `rate` = 1,
missing `spawns` = 8). A goal with a usable spot always has a target; the
view carries the chosen spot's `map`, `x`, `y`, `f`, `npcName`, `lvl` and
`alternatives`, and `CycleCandidate` walks the ranking.

### Focus: one person at a time

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

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

### Route choice ("Choose your path")

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

```lua
Polestar.Engine.GetRouteChoice(fromId) -> { from, fromTitle, recommended, chosen, joinsAt,
    options = { { id, title, zoneName, minLevel, maxLevel, pitch, pros, cons, seconds,
                  quests, steps, recommended, chosen, outleveled, open }, ... } } | nil
Polestar.Engine.ChooseRoute(fromId, toId) -> bool   -- remembers, loads, fires ROUTE_CHOSEN
Polestar.Engine.PendingRouteChoice() -> fromId | nil -- the active tab's finished guide waiting for a choice
Polestar.Engine.RouteChoiceFor(id) -> fromId | nil  -- the choice that offers guide `id` to this character
Polestar.Engine.RouteOffered(fromId, toId) -> bool  -- fromId's `next` names toId and its `when`, if any, holds now
Polestar.Engine.NextGuide(fromId) -> id | nil       -- where the hand-off goes now; nil: none offered, or the player chooses
Polestar.Engine.OfferRouteChoice() -> fromId | nil  -- /polestar path: fires ROUTE_CHOICE
Polestar.Engine.GuideFocus(id) -> x, y | nil       -- live-frame percent of where the guide is for this character, for the
                                                    -- guide menu's zone art: open in a tab and unfinished, the current
                                                    -- step's first goal on the guide's zone map (`zone`); else (or when
                                                    -- that step is elsewhere) its first goal on it: a route option's focusX/Y
```

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

### Tabs (several guides at once)

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

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

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

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

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

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

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

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

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

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

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

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

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

Waypoint:

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

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

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

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

---

Next page: [Theme](https://polestar.guide/docs/contributors/architecture/theme.md) · Every docs page: https://polestar.guide/llms.txt · All docs in one file: https://polestar.guide/llms-full.txt
