# Toolchain

> Node ≥ 24, zero npm dependencies

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

`node tools/polestar.js <command> [args]`. Each command is a module
`tools/commands/<name>.js` exporting:

```js
module.exports = { name, summary, usage, async run(argv) { return 0 /* exit code */ } }
```

The dispatcher discovers them; adding a command never edits a shared file.
Shared helpers: `tools/lib/paths.js` (the repository's folders), `tools/lib/cli.js`
(argument parsing, diagnostics), `tools/lib/game/` (XP rules, races and
classes), `tools/lib/lua.js` (run Lua 5.1, convert Lua data to JSON),
`tools/lib/tga.js` (anti-aliased TGA textures).

Every command, every generated file and the rebuild order: [tools/README.md](https://polestar.guide/docs/contributors/tools).

## Packs (pack mode)

The guide commands work on a pack: the folder given as their one argument
or in `--pack <dir>` (one of the two; it must hold `pack.json`), else the
nearest folder holding a `pack.json` (this one or a parent), else our
`guides/` (`tools/lib/pack/locate.js`). `tools/lib/pack/manifest.js` reads and
checks `pack.json`; `tools/lib/pack/format.js` holds the contract constants
(`FORMAT`, `NEEDS`, the id rule, reserved ids, the generated-folder marker);
`tools/lib/pack/build.js` builds a pack's files; `tools/lib/dsl/project.js`
loads a pack with our guides as context (their `next` targets and chains seen,
their own diagnostics not reported).

What `compile` writes into a pack's folder: Core, "Guide data (compiled Lua)".

| Command | In a pack |
|---|---|
| `new [dir]` | writes a pack from `tools/templates/pack/` (the CI workflow and devcontainer only when the clone has an `origin`) |
| `lint`, `compile` | checks with our guides as context; writes `<pack>/dist/<folder>/`; `--format text\|json\|github` |
| `export <file.guide>` | lints the file's pack (the nearest `pack.json` above it) and prints its one guide as an import string (`tools/lib/pack/importstring.js`; PACK_FORMAT.md, "Import strings"); refuses a file with errors, of several guides, of Polestar's own, over 60 steps or 50,000 characters. `play` and `dev --play` give the same string (`POST /api/import`, the page's Copy import string) |
| `doctor` | Node, Lua, reference data, AddOns folder, `pack.json` |
| `find` | quests, NPCs, objects, items, inns, flight points and our guides as guide lines (`--json`); `--kind inn\|flight` without a text lists every inn or flight point (from `travel.json`) |
| `playtest` | every guide to the end per class (`tools/lib/playtest.js`, also behind `tests/guides.test.js`); a guide starts with the prerequisites of its accepts that the guides chaining into it turn in already done, and those they leave for it only to hand in in the log, as for a player arriving on the route |
| `play`, `dev` | the playground with the pack built into a temp folder; `dev` watches and builds into AddOns |
| `install`, `pack` | builds and installs the pack; writes `<pack>/dist/<folder>-<version>.zip` |
| `record draft`, `sim compare` | drafts into `<pack>/drafts/` under `<id>/draft/`; scores the pack's chain beside ours |

`lint`, `compile`, `dev`, `play`, `install` and `pack` build a pack through
`buildPack` (`tools/lib/pack/build.js`). When the data folder is missing or holds none of
the core files, it reads no guide and returns one error (`noReferenceData`)
with `noData` set; the command prints that error and exits 1, and `dev`
checks this once at start instead of watching. `--no-data` (`lint`, `compile`,
`dev`, and `install` and `pack` on a pack) builds with empty data instead and
cannot be combined with `--data`, `--context-data` or `--sim`.

The folder rule (PACK_FORMAT.md, "TOC fields") is `packDirRefusal` in
`tools/lib/dsl/project.js`. Lint's pack rules mirror the runtime's
(`tests/packs-parity.test.js`); our house rules (elites in Leveling guides)
are warnings in other packs.

## Zone art overlays (`maps overlays`)

`node tools/polestar.js maps overlays [--build <ver>] [--offline]` writes
`addon/Polestar/UI/ZoneArtOverlays.lua` (`tools/lib/maps/overlays.js`) from
the client's DB2 tables, as the game-table export CSV cached under
`data/sources/<tables>/<build>/`: `UiMapXMapArt` (every art a map shows, any
phase), `UiMapArt` → `UiMapArtStyleLayer` (layer 0's tile size), then each
`WorldMapOverlay` of those arts (texture size, offset) with its
`WorldMapOverlayTile` files (layer 0) in row-major order; `UiMap` names
the comments. Keyed by UiMapArtID so the addon can tell whether the client
shows the art it was built for. Empty (0 x 0) rows are dropped; a row whose
tiles do not fill its grid is dropped with a warning. The default build is
the Forever client's (1.60.1.70338, product `wow_classic_beta`). It lives in
the core addon, loaded right after `UI/ZoneArt.lua`, because the core is what
draws it (the menu and the route cards work without any pack), and it
registers through `ZoneArt.RegisterOverlays` the way `MapFrames.lua` and
`Maps/ZoneLevels.lua` register their generated tables.

## Codex (`codex build`)

`node tools/polestar.js codex build [--addons <dir>] [--build <version>] [--offline] [--out <dir>] [--local-extras]`
merges `data/forever/*.json` (the Era quest database, the community quest database facts, recordings,
corrections, the web database and web XP facts), the Forever client's tables
(the game-table export, cached under `data/sources/<tables>/<build>/`: the newest 1.60+
`wow_classic_beta` build the game-table export lists, as `data build` picks its
`--forever-build` (`pickBuild` in `tools/lib/data/<tables>.js`); `--build`
names one; offline or with the game-table export unreachable, the newest cached build
holding every table the Codex reads; `meta.json` records it), the installed
the leveling gear lists (MIT) and Polestar's own guides into
`data/forever/codex/*.json`, then emits the load-on-demand pack
`addon/Polestar_Codex/` (`tools/lib/codex/pack.js`) with its
`LICENSE-DATA.txt`, which lists every source with its version and license.
Both are build products (gitignored); the pack is part of the release:
`pack` puts it in the zip and fails without it, `install` copies it when
built.

- Release-safe by default. `--local-extras` also merges three inputs that
  may not be redistributed: the loot-table addon (GPL-2.0, incompatible with
  Polestar's GPL-3.0-only), the rares database (All Rights Reserved) and
  `data/forever/dungeons.json` (DungeonJournal, no license). That build's
  `meta.json` and `Meta.lua` carry `localExtras = true`, its
  `LICENSE-DATA.txt` says not to share it, and `pack` refuses it, as it
  refuses a pack whose sources name any of the three
  (`releaseRefusal` in `tools/lib/codex/pack.js`). It exists for comparison
  on the developer's machine.
- Importers, one per installed addon, all reading through
  `tools/lib/consume/lua.js` (`addonVersion`, `readAddonLua`: the installed
  addon's files, found with `tools/lib/addons.js`, run sandboxed in Lua 5.1
  with the namespace the client passes them): `<leveling-bis>.js` (item
  sources: quest rewards, drops, crafts); with `--local-extras` also
  `<loot-db>.js` (DungeonsAndRaids incl. `forever.lua` and `droprate.lua`,
  Collections, Crafting, Factions, PvP) and `<rares-db>.js` (rares, their
  loot, treasures, mount and pet items). Each returns plain JSON with a
  `meta` naming its source, version and license.
- Client tables: `Item`, `ItemSparse` (names, quality, levels, prices, the
  reputation and PvP rank an item requires: rep and pvp sources), `ItemEffect`,
  `ItemXItemEffect`, `SpellEffect`, `SpellName`, `SkillLine`,
  `SkillLineAbility`, `Mount` and `BattlePetSpecies` (the summon spells that
  make an item a mount or a companion), and for the dungeons `Map`,
  `AreaTable`, `DungeonEncounter`, `LFGDungeons`, `MapDifficulty`,
  `ContentTuning`; the Classic Era `Item`, `ItemSparse`, `ItemEffect` and `Map`
  fill items Forever's tables lack and tell Forever's own instances apart.
- Dungeons and raids (`tools/lib/codex/instances.js`, release build): every
  `Map` instance that Forever's dungeon finder (`LFGDungeons`) names, or that
  belongs to the original game (map id below 1000) and has `DungeonEncounter`
  rows; Season of Discovery maps the client also carries stay out. `forever`
  = absent from the Era client's `Map`. Levels `[entry, min, max]`: the
  `MapDifficulty` "at least level N" message, the `ContentTuning` minimum of
  its dungeon-finder entries, the highest boss level (at most 60). Bosses:
  the `DungeonEncounter` rows of the normal difficulty in `OrderIndex` order,
  each matched to `npcs.json` (a recorded encounter's NPCs, else the same name
  among NPCs placed in the instance, else the same name preferring elites
  without open-world spawns at the instance's level, else one spawnless NPC
  whose name starts with it or is within two letters of it, of several the
  one placed inside (Kirtonos the Herald, not its spell visual); "A and B"
  encounters name both; an encounter naming no NPC takes the chest placed
  inside whose name ends with its name, The Seven: Chest of The Seven). A
  boss's loot (at most 30 items) is its own table first: its `items.json`
  drops at 1% or more or at an unknown rate, and its the web database drops outside
  the world-drop pool at any rate (Thermaplugg's schematics, Dextren Ward's
  plans); then the web database's `commondrop` rows (world books, world blues) at 1%
  or more, so those no longer crowd out a boss's own loot (Onyxia's Sacks
  of Gems); each part best chance first. The entrance (`chooseEntrance`): a recorded one
  (Recorder `enter`, `data/forever/instances.json`), else the curated table
  `tools/lib/codex/entrances.json` (per instance key: the point on the zone
  map where one walks in, `{ key, instance, map, x, y, source, note }`, each
  with its public source, checked against the corpse point and our guides'
  gotos; every dungeon and raid in the Codex, Forever's own included, from
  the community quest database's Forever dungeon table, the community wiki and the Forever web database),
  else a guide's `goto x,y "<instance name> ..."`, else the `Map` Corpse
  point (where a ghost comes back: near the way in, not at it; the
  Deadmines' is the portal deep in the Moonbrook mine, 200 yd from the
  Defias Hideout one walks in at) on the zone holding it, marked
  `approx: true` (packed as a fifth entrance field `a`, read as
  `Codex.Instance(key).entrance.approx`). `AreaTrigger` rows name no instance
  and the trigger nearest a Corpse point is as often an inn or city trigger,
  so they are not used. Every
  NPC placed in an instance (`npcs.json` `instances`, from the community quest database and
  recordings, or the web database's `location` naming an `AreaTable` area of the
  instance's map) belongs to it: its drops carry the instance. An instance's
  boss or mob the data ranks rare is not a rare unless the rares database lists it.
  `--local-extras` takes the instances from the loot-table addon and `dungeons.json`
  instead.
- The Forever web database per entity (`tools/lib/codex/<web-db>.js`, release build
  only, lowest precedence). `codex build` reads the Forever web database facts
  cached under `data/sources/<web-db>/codex/`, and builds without them when
  there are none. The cache holds the web database's NPC, object
  and item list rows by id range (`lists/<type>.json`: id, name, levels,
  classification, boss flag, `location` and `envChange`, how Forever differs
  from Classic) and each fetched entity's facts
  (`<npc|object|item|spell>/<id>.json`). `codex build` reads the NPC rows
  for one fact only: the level of an NPC neither our data nor a page gives
  one (286 NPCs); a row's rank, boss flag and place are no page's, so rares,
  bosses and parity's verdicts read only pages. From the pages: an NPC's
  level, rare rank and spawns where ours lack them (and the rares only
  the web database places), drops and object contents with chance = looted count /
  looted kills, an NPC's or object's `location` (the areas its page entry
  names) and the web database's boss flag, and the reputation ("Requires <Faction> -
<Standing>") and PvP rank ("(Rank N)") an item's Forever tooltip asks for.
An instance also lists, after
its encounters and by level, the NPCs placed inside that the data or
the web database ranks rare or boss (or flags boss), or that alone drop an item of
quality 2 or better at 5% or more, each with a `role`: `rare` (ranked
rare), `event` (the Era quest database and the community quest database know no static spawn: summoned or
scripted, the Ring of Law's fighters, the Tier 0.5 summons), else
`optional`; then the chests placed inside (no gathering node) that hold
more than one item (one is a quest object) and alone an item of
quality 2 or better (at 5% or more, or an unknown rate), as bosses with an
`object` and no `npc` (role `optional`). A boss is `{ name, npc, items }`,
plus `object` and `role` when set; the pack writes its ref as the npc id,
or `o<object>`, then `r`/`e`/`x` for the role (an old pack's bare npc id
still reads), and the Dungeons Bosses tab says Chest and the role beside
the level.
- Attunements and keys (`tools/lib/codex/attunements.js`, release-safe):
the cited table `tools/lib/codex/attunements.json` names per Codex instance
each attunement's goal only (the quests that end it, one of them with
`any`, or the key item), whether everyone entering needs it or one in the
group, and our one-line note, each entry with its public sources (the web database
Forever, the community wiki). The steps come from the Codex: every
prerequisite of a goal (all of `pre`, the `preAny` of the entry's side),
oldest first, each with `after`/`afterAny`; a quest only an item starts
gets that item's drops before it (Blackhand's Command from the Scarshield
Quartermaster); an item goal adds the item's own sources (a dropped key's
drops and chests, best chance first, three at most; Aqual Quintessence's
standing and the NPC handing it out). `links` add a prerequisite our quest
data lacks, with its source (Forever's City of Dalaran: Blood in the
Streets after Dalaran Patrols and Key to the City, Heart of Disruption
after it). `inside` places an NPC or object of a chain that has no
open-world spawn and no Codex instance in the instance it is in, each with
its the Forever web database page (Marshal Windsor, the Monument of Franclorn
Forgewright and the Core Fragment in Blackrock Depths; the Inconspicuous
Documents and Drakkisath's Brand in Blackrock Spire; Doan's Strongbox in
Scarlet Monastery); each attunement carries the entries its steps meet as
`inside: [{ kind, id, instance }]`. `removed` lists what Forever took out,
with its evidence (the Seal of Ascension: 4743 no longer rewards it, no
Forever `ItemSparse` row), read only by the local comparison. Covered: Molten Core (Attunement
to the Core; Aqual Quintessence), Onyxia's Lair (the Drakefire Amulet,
each side's chain), Blackwing Lair (Blackhand's Command), Naxxramas (one
of the three Dread Citadel quests), Scholomance (the Skeleton Key, each
side), Blackrock Depths (the Shadowforge and Prison Cell keys), Stratholme
(Key to the City), Dire Maul (Crescent Key), Gnomeregan (Workshop Key),
Scarlet Monastery (The Scarlet Key), the City of Dalaran (the Horde's
Dalaran Sewer Key). A goal the Codex lacks drops its entry with a warning
(`codex build` prints it); an instance the build lacks has none. The
journal puts each list on its page as `attunement` and counts them
(`meta.counts.attunements`, the summary's `attune = { A, H }`).
`auditReport(journal, attune, codex)` writes the local comparison with
the attunement data (`codex attune`, `data/local/attunements-audit.md`): per the attunement data
entry the attunement of ours sharing most quests, the quests only one side
lists and why ours has or lacks them (in our chain, a follow-up of our
goal, not a prerequisite in our data, not in our Codex), then our verdicts
on which source is right, kept in `data/local/attunements-verdicts.md` so
a rerun keeps them.
- Rares and treasures: NPCs ranked rare (4) or rare elite (2) in `npcs.json`
(the Era quest database, the community quest database, recordings), with their spawns and loot; treasures are
objects with the `chest` role, with their loot. Loot (`Codex.Loot`, where
the Rares view picks a signature drop) is every drop or object candidate
naming it, its own first (items no other NPC or object drops or holds:
Bayne's Bite, Serrated Shadowclaw), then best chance first, at most 150 (a
rare's candidates run to hundreds, median 286; 150 keeps every drop the
internal build names that the Forever web database confirms, for about 215 KB of
pack). An item keeps its 10 likeliest droppers (6 objects, 10 vendors) plus
the drops of its instances' bosses.
- Precedence for a fact found in several places follows `docs/SUITE.md`;
sources are deduplicated by type and id, keep their `from` provenance and the
best-known drop chance.
- Output: `meta.json` (`sources`, `localExtras`, `counts` by kind, `coverage`
= items with at least one source, `pack` = files, bytes and measured
memory), `items.json`
(`{ id, name, quality, kind: item|mount|pet|recipe|tabard, spell, reqLevel,
repFaction, repStanding, reqSkill, reqSkillRank, status, sources }`; a source
is `{ type: vendor|drop|object|quest|craft|rep|pvp|
container|world, from, ... }`), `npcs.json`, `objects.json`, `quests.json`
(with `pre` = every blocking prerequisite, `preAny`, rewards from recordings
and the leveling gear lists, `xp` = the full XP reward from `data/forever/quests.json`
where known (what a guide or dungeon is worth in levels: Core/Levels.lua),
and `<web-db>` on each quest the Forever web database's zone,
dungeon and raid lists carry: `{ zone, type, side, choices, items, money,
rep }`, the zone or instance category and the list's rewards, read by the
journal), `instances.json`, `zones.json`, `factions.json`,
`spells.json` (mount and pet aura spell → item, class), `frames.json` (Era
bounds for the pack's maps), `journal.json` (the Dungeons pages).
An item's `status` is `nosource` without a source, and `retired` for a mount
or pet whose every source is an Era vendor row (the Era quest database provenance) and,
in a release build, whose summon spell's `Mount` row the Forever client
flags hidden until known (`Flags` 0x40: kept only for those who already
have it; the still-sold Black Stallion is not flagged), or, with
`--local-extras`, that the loot-table addon Forever's collection lists do not name: the
stock the pre-1.4 epic mounts were retired from. A curated guide's
`collects` keeps an item obtainable, and Forever-only rows are trusted.
Companions have no such client flag: a release build retires none.

- Parity (`tools/lib/codex/parity.js`): a release build compares itself,
  entity by entity, with the last `--local-extras` build in
  `data/local/codex-extras` (when there is one), writes
  `data/local/codex-parity.json` and `.md` (per kind: total, matching,
  missing and different rows; a difference whose value comes from a
  better-evidenced input says so and counts as a match; a corpse point is
  never taken for an entrance) and records the match per kind in its
  `meta.parity`; without the internal build it claims none. A confirmed
  missing drop or loot row that the build knows but a cap left out (the
  merge's `evidence.cut`, not shipped) says so ("cut by a cap") and is
  counted on its own line. A chest boss matches the internal row named after
  its object (Chest of The Seven); a confirmed missing boss that Forever's
  sources place outside every instance (the web database `location` or spawns off the
  instance maps' areas: Techbot, Maraudon's Desolace caves) or rank an
  ordinary NPC (no the web database boss flag, not rare, no green or better item
  only it drops at 5% or more) says so, counted as "no boss of it in
  Forever's sources". `codex parity
  [--ours <dir>] [--against <dir>]` reruns the comparison. The report also
  lists what only play can fill and the Recorder event that fills it
  (Forever instances and their entrances: `enter`, `encounter`; Forever
  rares: `rare`, `slain` + `drop`; quest givers, enders, rewards: `accept`,
  `turnin`). On 2026-10-15 (Forever client 1.60.1.70374, the same
  `data/forever`):

  | Kind | Internal | Release matching | Match |
  |---|---:|---:|---:|
  | instances | 44 | 37 | 84.1% |
  | instance levels | 29 | 29 | 100.0% |
  | instance entrances | 14 | 13 | 92.9% |
  | bosses | 373 | 333 | 89.3% |
  | bosses with loot | 326 | 324 | 99.4% |
  | boss loot items | 2306 | 1977 | 85.7% |
  | drop chances | 20707 | 17533 | 84.7% |
  | rares | 413 | 411 | 99.5% |
  | rare positions | 387 | 384 | 99.2% |
  | rare levels | 406 | 406 | 100.0% |
  | rare loot | 51604 | 50787 | 98.4% |
  | treasures | 65 | 60 | 92.3% |
  | treasure positions | 31 | 31 | 100.0% |
  | treasure loot | 4502 | 4498 | 99.9% |
  | items with a source | 16205 | 16187 | 99.9% |
  | items: drop | 8806 | 8743 | 99.3% |
  | items: quest | 2429 | 2429 | 100.0% |
  | items: vendor | 2248 | 2248 | 100.0% |
  | items: object | 3288 | 3287 | 100.0% |
  | items: world | 4152 | 4150 | 100.0% |
  | items: craft | 1797 | 1797 | 100.0% |
  | items: container | 2095 | 2095 | 100.0% |
  | tabards | 40 | 40 | 100.0% |
  | mounts | 136 | 136 | 100.0% |
  | items: rep | 980 | 975 | 99.5% |
  | pets | 90 | 90 | 100.0% |
  | items: pvp | 763 | 763 | 100.0% |
  | journal quests | 833 | 823 | 98.8% |
  | quest givers | 582 | 568 | 97.6% |
  | quest enders | 618 | 608 | 98.4% |
  | quest XP | 698 | 689 | 98.7% |
  | quest chains | 481 | 474 | 98.5% |
  | quest rewards | 582 | 577 | 99.1% |

  The gaps: seven Forever instances the loot-table addon lists ahead of release are not
  in the client (no `Map` row); the Hall of Thanes' entrance stays
  different (the community quest database's Ironforge 27.6,47.8, where the Forever web database's
  comments put the quest givers "near the entrance", against 43.5,52) until
  a recording settles it; the loot-table addon's extra bosses (the Ring of
  Law's fighters, rare spawns, chests) are not `DungeonEncounter` rows;
  reputation standings that vendors gate in gossip, and battleground rewards
  the loot-table addon files as PvP, are in no client table (`ItemSparse` carries no
  `MinFactionID` or `RequiredPVPRank` for them, and the Forever web database's Sells
  standing is that same `MinReputation`). A vendor's, giver's or quest's gate
  comes from the cited table `tools/lib/codex/repgates.json`
  (`loadRepGateTable`, provenance `curated`: rows `{ item, name, faction,
  standing, npcs: [vendors or givers] | quest (+ choice), source, note? }`,
  one per item and faction, each citing the community wiki, else the web database
  Classic's reputation guides or the community quest database; the Alterac Valley and Warsong
  Gulch supply officers, Argent Dawn quartermasters, Timbermaw Hold, Duke
  Hydraxis, Rin'wosho the Trader's barter quests, which the row also makes
  the item's quest source since the community quest database lists no rewards for them), from a
  curated guide that collects the item and reaches the standing before its
  `buy` (`curatedRepGates`, provenance `guides`: the Winterspring
  Frostsaber, the Stormpike Battle Charger, the Frostwolf Howler), and a
  quest's from the community quest database (`minRep` or a reputation objective, the higher: a
  `rep` source naming no vendor, and the quest's `repFaction`/`repStanding`,
  which blocks its reward row in Lookup; a table row's standing replaces it,
  with a warning when they differ). Precedence: client, then the table, then
  a guide. A rep source at an item's vendor or giver sets its
  `repFaction`/`repStanding`; Lookup gates each vendor or giver row by the
  rep source standing at that npc, else (a purchase) by the item's own,
  unless that is the other side's faction and a gate of the npc's side exists
  (`Lookup.PurchaseRep`: Alterac Valley wares both sides' supply officers
  sell). A table row whose item, vendor or quest the Codex lacks is a build
  warning. What stays missing: the Insignia of the Dawn and of the Crusade
  (quest rewards and turn-ins, no standing), the Bloodsail Shirt, Sash and
  Pants (in the Buccaneer's Uniform, whose quest, Dressing the Part, carries
  the Neutral gate) and the world-drop Warbear patterns 15742 and 15754
  (Timbermaw Hold sells 20253 and 20254, gated in `ItemSparse`). An NPC that
  casts a spell creating an item (client `SpellEffect` 24; who casts it,
  the Forever web database's spell page in the cache) hands it out: a `gift { npc,
  spell }` source (Duke
  Hydraxis's Aqual and Eternal Quintessence). 26 Forever rares no
  database ranks yet. The 251 confirmed drop chances the release lacks are
  all cut by an item's 10-dropper cap (the internal build keeps the loot-table addon's
  instance trash past it; every dropper of every item would be 1.6 million
  sources against 74 thousand).

## Reference data (`data/forever/`, built by `data build`, gitignored)

```
quests.json   { "<id>": { id, title, level, minLevel, races, classes, start: { npc: [], object: [], item: [] },
                          end: { npc: [], object: [] }, pre: [ids], excl: [ids], next, objectives: [
                          { type: "kill"|"item"|"object"|"event", id, count, text } ], objectiveText, zone, xp,
                          rewards?: { items: [ids], choices: [ids], money }, source: [] } }   (rewards: recorded only;
              count: the objective text's, else <era-db>'s, else <server-db> <server-db> quest_template's for the same id or the
              quest's one objective of that kind, else null; tools/lib/data/<quest-db>-merge.js fillCounts)
npcs.json     { "<id>": { id, name, level, levelMax?, rank?, faction, roles?, spawns: { "<uiMapID>": { f: "e"|"l", pts: [[x, y], ...],
                          more?: [[x, y], ...] } }, instances?: [instance map ids], encounter?: DungeonEncounter id, source: [] } }
objects.json  { "<id>": { id, name, roles?, spawns: { ... same ... }, instances?: [instance map ids], source: [] } }
items.json    { "<id>": { id, name, drops: { npc: { "<id>": chance }, object: { "<id>": chance } }, vendors: [ids], source: [],
                          seen?: { npc|object: { "<id>": { n, of } } } } }   (chance in percent; seen: recorded loot counts)
zones.json    { "<uiMapID>": { id, name, areaId, parent, type } }
map-frames.json { era: { "<uiMapID>": { c, left, right, top, bottom } }, forever: { ... same, Forever client bounds ... } }
professions.json { "<skillLineID>": { id, name, kind: "primary"|"gathering"|"secondary", ranks: [ { name, max, minLevel } ],
                  trainers: { Alliance: [npcIds], Horde: [npcIds] }, recipes: [ { spell, name, item, itemCount, reagents: [ { item, count } ],
                  learnedAt, yellow, green, grey, source: "trainer"|"vendor"|"drop"|"quest"|"item", recipeItem, cost } ] } }
factions.json { "<factionID>": { id, name, side: "Alliance"|"Horde"|null, parent, source: [] } }   (Forever Faction table wins by id, Era fills)
races.json    { "<raceFile>": { "<Alliance|Horde>": { id, bit, classes: [classFiles] } } }   (Forever client ChrRaces, ChrClasses,
              CharBaseInfo; written only when those tables are cached; see tools/lib/data/races.js)
spawnz.json   { npc: { "<id>": [[instanceMap, x, y, z], ...] }, object: { ... } }   (<server-db> <server-db> creature and
              gameobject spawns on maps 0 and 1, UnitPosition's frame (x north, y west), 0.1 yd, each id's spawns sorted;
              one id per line; the compiler's goal `z`, tools/lib/dsl/heights.js; left out, with one warning, when
              --offline has no cached dump; tools/lib/travel/<server-db>.js loadSpawnHeights)
meta.json     { builtAt, sources: [ { name, url, revision, license } ], counts: { ... }, liveFrameMaps: [uiMapIDs],
                <quest-db>: { notInStore: [quest ids], changedFields: { field: count } } }
dungeons.json { source: { name, addon, title, version, author, license }, dungeons: [ { key, name, levels: { min, max },
                zone, zoneName, entrance: { map, x, y, f }, abbreviations, factions, bosses: [ { name, npc, level, rare,
                loot: [ { item, name, slot, quality } ] } ], quests: [ quest ], chainQuests: { "<id>": quest } } ] }
                quest = { id, title, faction, level, minLevel, classes, xp, xpSource, money, rewards: { items, choices },
                start, end: { map, x, y, f, source: "data"|"data-npc"|"dj"|"web"|"entrance", kind, id?, item?, name, inDungeon? },
                chain, followUps, startItem, startBoss, startNear, bosses, hidden?, inData }
```

Spawn points per entity per map: `pts` holds at most 24 (`data build --cap`),
spread evenly over the entity's spawns (farthest-point sampling); `more` the
distinct points the cap left out, sorted by x then y, only when there are any.
Every consumer reads the evenly spread `pts` (the simulator, zone levels,
Codex, the profession generators), except the ones that mark where an entity
spawns, which read `pts` and `more`, every known spawn: the guide compiler's
spawn areas and near-spawn lint, and QuestDB's marker clusters.

`dungeons.json` is built by `dungeons build` (`tools/lib/data/dungeonjournal.js`)
from the installed DungeonJournal addon (no license: local only, see
THIRD_PARTY.md). Bosses are in kill order; `entrance` and DungeonJournal
positions are live frame (`f: "l"`). Quest positions take our data first (the
quest's giver/ender with spawns, DungeonJournal's map picking the spawn; else a
spawning NPC of the named giver), then DungeonJournal's maps, then positions
researched by hand (`WEB_POSITIONS`, with a reference); a giver inside the
instance gets the entrance and `inDungeon`. `chain` is DungeonJournal's
prerequisite list with our data's `pre` added in doing order; `xp` is our
data's, else DungeonJournal's observed Forever XP. The guide compiler reads
nothing from `dungeons.json` (its facts stay local, THIRD_PARTY.md): a quest
only it knows needs its title (and level) written in the guide.

The hub's Dungeons section and the pick-up guide built from it: "Dungeons" above.

`professions.json` is built by `profession build` from the Forever client's
DB2 (`wow_classic_beta` 1.60+ via the game-table export: SkillLine, SkillLineAbility,
SpellName, SpellEffect, SpellReagents, ItemSparse, Item, ItemEffect,
ItemXItemEffect, ...; `tools/lib/professions/tables.js`). Forever changed
recipe thresholds, reagents and names from Classic (issue #109), so Classic
Era only fills gaps: a table the game-table export has no Forever export of (SpellTotems,
SpellCastingRequirements, SpellFocusObject) comes whole from the newest Era
build, and the spells and items the Forever tables lack come from Era's rows.
Each profession also carries `mainTrainer { Alliance,
Horde }` and `materials { itemId: { name, buy, buyCount, sell, quality,
vendors { A, H }, limitedVendors { A, H }, bop, madeBy, gather { herb, mine, fishing },
drops, dropCount } }` (`bop`: the client's ItemSparse binds it when picked
up, so the Auction House never has it); ranks carry `minSkill`, `spell`, `trainers { A, H }` and,
for secondary skills, `book`/`quest`; recipes may carry `enchant`,
`learnedAtEstimated`, `requires` (specialisation), `reputation { faction,
standing }`, `tools`, `focus`, `races`, `classes`, `skillUps`. Season of
Discovery recipes (spell id ≥ 400000 that the Era client's SkillLineAbility
lists too; Forever's own recipes, past 1,000,000, it does not) and the ones Forever
retired (`AcquireMethod` 3, such as Alchemy's healing potions, which Forever
teaches in First Aid) are excluded. A crafting profession whose last points no
trainer or vendor recipe reaches (Forever Tailoring past 295: Spinning Wheel,
Loom) is drafted as far as they go, with a last step naming what is left.

`races`/`classes` are Blizzard bitmasks (Human 1, Orc 2, Dwarf 4, NightElf 8,
Undead 16, Tauren 32, Gnome 64, Troll 128; Warrior 1, Paladin 2, Hunter 4,
Rogue 8, Priest 16, Shaman 64, Mage 128, Warlock 256, Druid 1024), `null`
means unrestricted. The Era quest database masks are **Era** masks: Skyborne has no bit there,
so never read an Era-only mask as excluding Skyborne. The community quest database masks add
Forever's Skyborne bits 2^32 (High Order, Alliance) and 2^33 (Windshapers,
Horde), past 32-bit `bit.band`: split them arithmetically (`races % 2^32`,
`floor(races / 2^32)`). `Polestar_QuestDB` keeps the low 32 bits. Spawn `f` is the frame of
those points (`"e"` Era, `"l"` live Forever). A frame's `c` is the instance map id (0 Eastern
instances.json { "<instance map id>": { id, name, type: "party"|"raid", entrances: [ { map, x, y, n } ],
                 encounters: { "<DungeonEncounter id>": { name, npcs: [ids], kills } }, source: ["recorder"] } }   (recorded only; {} without)
Kingdoms, 1 Kalimdor; 2991, 2997 for Forever's new islands): the `continentID`
`C_Map.GetWorldPosFromMapPos` returns.

Optional extras: quests `skill` (profession requirement), `event` (holiday),
`escort` (true or false, only from corrections.json: whether it is an escort
where its own text does not say so right; tools/lib/dsl/escort.js),
`xpSamples` (how many recorded observations back `xp`); event objectives `map`,
`f`, `x`, `y`; npcs `levelMax`, `rank`, `roles` (flightmaster, innkeeper,
vendor, trainer, ...), `subName`; objects `roles` (herb, mine, chest, ...); items
`containers` (items that contain it), `questItem: true` (the community quest database item class 12:
drops only while on the quest), `duration` (seconds the item lasts once the
player has it: the Forever client's ItemSparse DurationInInventory, when that
table is cached; the timed-item lint reads it). A drop chance is `null` when the community quest database lists the
dropper but no source has a rate. From the community quest database, each only when it has a
value: quests `preAll` (all must be done; preQuestGroup), `preAny` (any one
suffices; preQuestSingle), `breadcrumbFor` (the quest this optional breadcrumb
leads to), `breadcrumbs` (breadcrumbs leading here), `parent` (must be active;
parentQuest), `children` (childQuests), `requiredItems` (needed, not
objectives), `sourceItem` (handed over at accept), `maxLevel`, `skillLevel`,
`repeatable`, `reputation { faction, value }` (an objective), `minRep { faction,
value }` (requiredMinRep: the reputation value, 3000 Friendly, a character needs
to take it), `area` (AreaTable id
of the quest's zone or dungeon, the community quest database zoneOrSort > 0); kill objectives
`credit` (every npc id that gives the kill credit). `pre` keeps the Era quest database's
meaning (any one suffices, optional breadcrumbs included) for every quest
the Era quest database has; Forever-only quests take `preAny`, else `preAll`. `excl` is the
exclusive group: the Era quest database `close` united with the community quest database `exclusiveTo`.

### Sources and precedence

`data build` reads, lowest precedence first:

1. **The Era quest database** (MIT, git clone cached in `data/sources/<era-db>`): Classic Era
   quests, npcs, objects, items, drop chances, Era-frame coordinates.
2. **The community quest database's Forever store** (`<quest-db>-forever`): the `<quest-db>_Forever.toc`
   that a community quest addon for Forever installs. `--<quest-db> <dir>`, else auto-detected in
   the Forever client the way `install` finds it (client detection lives in
   `tools/lib/addons.js`: `detectAddonsDirs`, `foreverKind`). It's read in place
   (`tools/lib/data/<quest-db>.js` ports the community quest database's `src/meta/codec.lua`,
   `src/read/baked.lua` and `normalize.default`). Field layouts come from the
   installed `src/meta/*Meta.lua`, and support data from `support/Forever/*`:
   QuestXP, DropTables (chance = manual correction, else `<server-db>`, else
   the web database, as `DropDB.GetItemDroprate`), Zones (AreaID → UiMapID). It adds
   Forever-only quests, npcs, objects and items. On shared ones the community quest database wins
   on every scalar field it has a value for (titles, levels, masks, objectives,
   spawns), and Era-database-only fields stay. **Source lists are unions**: item
   droppers, drop objects and vendors, npc `roles`, and quest givers and enders
   list the community quest database's entries first. Its values win on conflicts (a drop chance), and
   the Era quest database's extra entries are kept. A missing the community quest database entry is not evidence
   that Forever removed something. Real removals, or wrong the Era quest database sources, go in
   corrections.json. Exceptions: npc `faction` stays the Era quest database's, because the community quest database's
   friendlyToFaction means "not hostile", so wildlife reads "AH". `pre` also
   stays the Era quest database's (see above). The Era quest database copies of a spawn on another map (a
   city and its zone) are kept when they lie within 30 yards of a community quest database
   spawn. The community quest database's Static Corrections are folded into the store. Its Dynamic
   (per-character) Corrections are not applied. The community quest addon and its database ship no
   license file (CurseForge says GPL-3.0; the maintainers say to treat them as
   all rights reserved, the community quest database issue #4447). The data stays in `data/`, and
   only the importer is in this repository.
3. **The Forever web database's change notes** (`tools/lib/data/<web-db>.js`,
   `--no-<web-db>` skips them, as does a build without the community quest database): the quest
   list of every zone and city page and of the nine class pages (class
   quests are filed there, not under a zone) on the Forever web database, read from
   `data/sources/<web-db>/zones.json`; without that cache this step adds
   nothing. A quest the web database marks updated with "Experience
   X instead of Y", "Level X instead of Y" or "Requires level X instead of Y"
   gets X where its value is still the old Y (for XP, also where it has none),
   and one marked "Renamed from Y" gets the web database's Forever name where its title
   is still Y (Chastise, formerly A Lack of Fear); any other value is left
   alone, and the quest's `source` gains `<web-db>:xp`, `<web-db>:level`,
   `<web-db>:minLevel` or `<web-db>:title`. Only rows whose category is the
   zone's area count: the web database answers an unknown zone path with a generic list.
   Its Forever database also lists datamined quests that are not live (`<NYI>`
   placeholders, Season of Discovery leftovers), so presence there is not
   evidence that a quest is in the game.
4. **Polestar corrections** (`tools/lib/data/corrections.json`, committed):
   facts the community quest database or the Era quest database get wrong or lack, each with its source in a `note`:
   quest fields (`set`), item sources to drop (`removeDrops`, `removeVendors`),
   npc/object fields (`set`: the Forever elite and rare ranks the community quest database's
   Forever store lacks, since it gives every Forever npc rank 0, and renamed
   Era records) and npc/object spawn maps (`addSpawns`). They are applied
   after the merge, only in a build that reads the community quest database, and the record's
   `source` gains `polestar-correction`. One
   cleanup is general rather than per quest: `pre`, `preAll` and `preAny`
   never contain the quest itself or an id from its `excl`. The community quest database
   preQuestSingle sometimes lists the whole exclusive group.
5. **Recordings** (`data/recordings`), which win over all of the above;
   finds (rares, kills, loot, treasures, instances; "Recordings" below) follow
   the rules of their own paragraph there.

`source` on every quest, npc, object and item lists its contributors in
that order: `<era-db>`, `<quest-db>-forever`, `<web-db>:*` (change notes), `polestar-correction`, the web XP tag, `recorder`, then the recorded XP tag (`recorder:xp-*`).
Blizzard map tables come from the game-table export: Classic Era (`wow_classic_era`) for
the Era frames the Era quest database's coordinates use, and the Forever client
(`wow_classic_beta` builds 1.60+, `--forever-build`, `--no-forever`) for
Forever zones and `map-frames.json` `forever`. Forever zone names win.

**Coordinate frames.** the community quest database's store holds **Forever-frame** percentages.
For the evidence, compare its points with the Era quest database's Era points for the same
entity. On Mulgore, Eastern Plaguelands, Redridge and Stormwind, the community quest database's
points match the Era quest database only after inverting the community quest database's own `EraToForever`
transform (`src/support/eraToForever.lua`). Median distance to the nearest
the Era quest database point, in map percent, raw vs inverted:

| Map | npcs | objects |
|---|---|---|
| Mulgore | 2.57 vs 0.61 | 5.10 vs 1.28 |
| EPL | 7.93 vs 0.08 | 8.52 vs 2.58 |
| Redridge | 4.25 vs 0.07 | 5.09 vs 1.40 |
| Stormwind | 14.02 vs 0.05 | 11.14 vs 0.05 |

Per entity on those four maps, 1244 were clearly closer inverted, 0 closer raw
and 114 ambiguous. On unchanged maps (Elwynn, Durotar) raw and inverted agree
(0.1-0.2 median for npcs), as they must when the frames are equal.
So the community quest database points are tagged `"l"` on maps whose Forever bounds differ from
the Era bounds by more than 1 yard (with Forever tables; otherwise the maps
eraToForever lists) and on maps without Era bounds (Forever-only: Zephras Isle
2521, Darkspear Islands 2524, Riverglades 2548, Shen'dralas 2652, Mount Hyjal
2482). Everywhere else they are `"e"`, the same point in both frames.
`meta.liveFrameMaps` lists the changed maps.

Quest `xp`: recordings first (`rewardXpBase` wins; otherwise the largest
`rewardXp` or turn-in `xpReward` observed while the player was at most 5 levels
above the quest, since Classic reduces XP beyond that, so an over-levelled
observation never overwrites a full one; `source` names `recorder:xp-base`,
`recorder:xp-log` or `recorder:xp-turnin`). Then the community quest database's QuestXP, with
the web database's "Experience X instead of Y" notes applied where it still has the old
value (above). corrections.json `xp` replaces both: Forever about doubled the
XP of many dungeon quests, and QuestXP still has the Classic value for some
(issue #394), so those take the XP shown by in-game evidence dated after the
1 October 2026 change that halved dungeon quests' extra XP (build 70170; a
client's quest cache gives difficulty and multiplier, its XP is QuestXP ×
multiplier, while web databases built from the DB2s give only the Classic
base), or an undated capture at the dated post-change rate (XP / base) of a
quest in the same dungeon: the extra XP was twice as much before the change,
so such a value can't be a pre-change one. Then, for quests
still without XP, the Forever web databases
(`--no-web-xp` skips them; results are cached in
`data/sources/webxp/quest-xp.json`): a Forever talents site (Forever beta client
cache, tag `<talents-site>:xp`), else a Forever changes site (the web database's Forever
database: the quest's reward, `<changes-site>:xp`, or its quest-chain list,
`<changes-site>:xp-chain`), else the XP on the web database's zone quest list
(`<web-db>:xp`). `meta.counts` gives `questXpFromWeb`, `questXpFrom<web-db>` and
`<web-db>Changed` ({ xp, level, minLevel, title }).

## Recordings (`data/recordings/<session>.json`)

Normalised from `PolestarRecorderDB` by `record import`:

```
{ schema: 1, build, session, char: { name, realm, race, class, faction }, started, ended,
  events: [ { t, e, ...fields } ], maps: { "<uiMapID>": { name, type, parent, c, left, right, top, bottom } } }
```

`record import --anonymize` (a file a player sent in) sets `char.name` and
`char.realm` to null and adds `char.id`, 8 hex digits of a SHA-256 of
"name-realm", which also replaces the name in the session key; no raw copy is
archived.

`t` = seconds since session start. Event kinds and fields:
`accept {q, title, qlvl, lvl, xp, map, x, y, npc, npcName, object}`,
`turnin {q, xpReward, money, lvl, map, x, y, npc, npcName}`,
`progress {q, oi, text, cur, max, map, x, y}`, `complete {q}`, `abandon {q}`,
`level {lvl, map, x, y}`, `zone {map, zone, subzone}`, `pos {map, x, y}`,
`kill {npc, npcName, map, x, y}`, `loot {item, itemName, count, map, x, y}`,
`gossip {npc, npcName, available: [q], active: [q], map, x, y}`,
`fp {location, npc, npcName, map, x, y}`, `taxi {from, to}`,
`bind {location, map, x, y}`, `trainer {npc, npcName, map, x, y}`,
`vendor {npc, npcName, map, x, y}`, `death {map, x, y}`, `mark {text, map, x, y}`.

Additive fields: `progress.type`; `kill`/`loot` may carry `q` and `oi`;
`accept`/`turnin` carry `object`/`objName` for game-object givers; `accept.item`
for item-started quests; `accept.rewardXp`/`rewardXpBase` (from the client's
`GetQuestLogRewardXP`, when cached); a `reward {q, rewardXp, rewardXpBase, lvl}`
event when the reward was not cached at accept, or for quests already in the log
at login; a session may carry `dropped: { kind: n }` when caps
were hit. Forever restricts `COMBAT_LOG_EVENT_UNFILTERED` to secure code, so
kills come from `UNIT_DIED`/`PARTY_KILL` correlated with objective progress.

Character progress: `skill {skill, skillLine, rank, max, map, x, y}`,
`rep {faction, factionName, standing, value, delta, map, x, y}`,
`explore {map, x, y, area, areaID, xp}`, `craft {spell, item, count, skill}`,
`learn {spell, name}`, `achieve {id, name, already}`. Session dumps from
`/psrec factions|skills|legacy`: `factions: { "<id>": { name, order, parent,
header, headerRep, standing, value, min, max, atWar, canToggleAtWar } }`,
`skills: { "<skillLine>": { name, order, rank, max, category, categoryID } }`,
`legacy: { categories, achievements, majorFactions, trees }`, each time-stamped
in `dumped: { kind: epochSeconds }`.

Route telemetry (`v = 1`), recorded while Polestar runs: `step {v, reason,
guide, step, title, guideTitle, time, lvl, xp, xpMax, rested, money, combat,
map, x, y}` with `reason` = `login` (session start or /reload), `step`
(`STEP_CHANGED`), `guide` (`GUIDE_CHANGED`: tab switch or new guide) or
`complete` (`GUIDE_COMPLETED`; no `step`). Only the active tab is snapshotted.
Also, with or without Polestar: `combat {on}` (`PLAYER_REGEN_DISABLED/ENABLED`),
`afk {on}`, `revive` (alive again after a `death`).

### Measuring routes

Recording is opt-in (`PolestarRecorderDB.enabled == true`, set by `/psrec on` or
Options > Recorder); while it is off the Recorder writes nothing, not even a
session. Testers turn it on, play with Polestar, run `record import`
(or keep `record watch` running), then `record report [--guide <text>]
[--json] [--top n]`. The report rebuilds step passes from every session and
character:

- Play time between consecutive events counts up to 5 minutes; time flagged
  AFK counts zero; time between sessions counts nothing. A `/reload` costs its
  few seconds and continues the step.
- Per character and guide, a frontier step: a later step closes its pass and
**Instances.** npcs and objects inside a dungeon or raid get `instances`
(instance map ids). The community quest database gives them no coordinates, only their areas
(`{ [areaId] = { { -1, -1 } } }`): an area's instance is its Forever
`AreaTable` ContinentID when that `Map` row is InstanceType 1 (party) or 2
(raid); without the `Map` table (an optional Forever table), an area no UiMap
shows on a continent other than 0 and 1. Recorded `inst` fields add to them.

  opens the next; returning to it (tab switch, reload, new session) continues
  the pass; an earlier step (Prev, Goto back, a step un-done) is a revisit,
  reported apart. Completion closes the last pass; passes still open are
  shown as `open` and kept out of the statistics.
- XP from the snapshots, as cumulative XP (recorded `xpMax` per level, else
  the 1.12 table), so level-ups inside a step count in full.

Per step: passes, median/p75 time, XP, XP/hr, combat share (the rest is
mostly travel), deaths, turn-ins, travel yards (taxis and jumps faster than
40 yd/s excluded), revisits. Per guide: time (completed runs), levels gained,
XP/hr. Then three lists: slowest steps by XP/hr; steps at least twice and
2 minutes over the guide's median step time (compiled packs carry no time
estimate to compare against); detours, steps with 600+ yd of median travel and
no XP or a third of the guide's XP per yard.

## Route planner (`planner`)

`node tools/polestar.js planner [<pack dir>...] [--port 8078] [--nav <dir>]` serves a local
page (127.0.0.1 only) that plays a guide route on the game's map and edits its
`.guide` sources. Inspired by Nivek_Gaming (@Nivek_Gaming on X), whose idea
the planner and the navigation grid both come from.

- **Coordinates** (`tools/lib/planner/geo.js`): world `{ c, wx, wy }` (the
  client's yards, `c` the instance map), map percent per frame (`f = "e"` Era,
  else live), and the renderer's plane `X = -wy`, `Y = -wx`.
- **Map art** (`planner art`, `tools/lib/planner/art/`): the Forever client's
  minimap blocks (WDT `MAID`, 8th field; ADT tile `(col, row)` =
  `map<col>_<row>.blp` = WDT entry `row * 64 + col`) and zone map art (UiMapArtTile plus
  every WorldMapOverlay), fetched by fileDataID from the game-table export into
  `data/sources/casc/<product>/<build>/`, built into `data/local/planner/art/`:
  `index.json`, a BC1 pyramid of 512 px tiles with 8 mips each (level 0 one
  ADT block, level 6 the whole grid; missing blocks in the map's sea colour),
  and one unfogged PNG per zone over its live frame. Local only, never shipped.
- **Playback** (`tools/lib/planner/timeline.js`): plays compiled guides goal
  by goal for one character with the simulator's priors (walk x detour, kill
  time by level gap and class, talk and use times, drop rates, flights over
  `travel.json` legs at 30 yd/s, boats with their waits, hearth with its
  cooldown, `levelOverhead` per level) and `tools/lib/game/xp.js`. The
  Timeline holds per step its times, split (travel, combat, interact, wait),
  levels, XP, goal places and spawn areas, the character's state at its start,
  plus a position track and events.
- **Edits** (`tools/lib/planner/source.js`, `goalfmt.js`): the parser records
  each goal's token spans (`goal.spans`, non-enumerable, so nothing compiled
  changes); edit operations rewrite only the tokens they change, regenerate a
  goal line from its fields when a form changes it, move steps and goals with
  their comments, and add an explicit `zone` line wherever a move would
  change the zone a goal's coordinates are read in. A result that parses worse
  than its input is refused.
- **Commands** (`tools/planner/commands.js`): one registry served as
  `POST /api/cmd`, as MCP tools (`planner mcp`: stdio JSON-RPC that proxies to
  a running planner or starts one) and, where the browser has it, WebMCP.
  Edits name the file's hash and are refused when it changed; they write the
  file at once, keep an undo history and are pushed to the page over
  `GET /api/events` (SSE), as are external changes (fs.watch). A full lint
  runs in a worker thread 600 ms after changes; `pack_compile` writes the
  addon folder as `compile` does. The server answers only requests naming it
  in Host, runs only JSON POSTs from no Origin or its own, refuses an icon
  GET (which may fetch from the game-table export) that the browser marks as another
  site's, and forbids framing (`X-Frame-Options: DENY`,
  `frame-ancestors 'none'`).
- **Navigation** (`tools/planner/navpool.js`; the grid itself: "Navigation
  grid" below): with a navigation grid (`data/local/nav`, `nav build`) every
  walk of the playback is routed on it with the character's movement profile
  (`tools/lib/nav/move.js` `profileFor`: mount from `mountLevel`, Ghost Wolf,
  Travel and Aquatic Form, Cheetah, indoor rules): the legs (to a goal's
  place, a boat, a flight master, a spawn pool away from the character) and
  the hops between spawn points inside a kill, collect or interact area
  (picked nearest first as before; kill and loot time stay the priors').
  `buildTimeline({ routes })` takes a synchronous route provider: a routed
  walk takes the route's seconds, its track follows the route's points (a
  leg with a mode per segment: swim, fall, climb, lift, indoor; a hop in
  fight mode), and the goal records `route { yards, byClass, falls }`; a
  walk the grid cannot route, or one not computed yet, keeps the
  straight-line estimate (a hop the spacing model's walk) and marks the goal
  (`estimate`, `routeReason`) and its step. Totals count `legs`, `routed`,
  `hops`, `hopsRouted`, `routeSeconds` and `estimateSeconds`. Flights keep
  their time and, with `flightPaths` (the Era client's TaxiPathNode points of
  each travel.json taxi leg, read from the cached
  `data/sources/<tables>/<build>/TaxiPathNode.csv`), their track follows the
  points the flight takes. The workspace builds a
  timeline with the cache as provider; the legs it misses (ends quantised to
  1 yd, plus the profile's speeds) are computed in a `worker_threads` pool
  (one Nav per worker; CPUs - 1, at most one per 4 GiB) while `timeline_get`
  answers at once with the straight legs and `routing { pending, done }`
  (SSE `routing` each second); when they are all in, the cached timeline is
  dropped and SSE `changed { timeline: true }` makes a page whose timeline is
  still being routed ask again (as does an SSE reconnect, and, when no
  `routing` event for its timeline arrives within 10 s, the page itself).
  Routes are appended to `data/local/nav/routes-<build>.jsonl`, `build`
  being a hash of the grid's `index.json`, `lifts.json` and the route code,
  so restarts are warm and a rebuilt grid (or edited costs) starts afresh;
  the timeline cache key holds the build. The choice between walking and a
  boat or a flight master still compares straight-line times.
  `nav_route` routes one walk for an agent; `GET /nav/index.json` lists the
  grid's tiles, `GET /nav/tile/<c>/<col>_<row>.png` draws an ADT tile's
  Walkable layer (per column: nothing walkable, steep, climb, swim only,
  indoor only; plain ground clear), and `GET /nav/aids.json` serves the
  arrow aids (`nav aids`, crossings schema) as world points.

## Navigation grid (`nav build`)

`node tools/polestar.js nav build [--continents 0,1] [--force] [--offline]`
builds `data/local/nav` from the Forever client's own files, fetched by
fileDataID from the game-table export into `data/sources/casc` (local, never shipped):

- **Geometry** (`tools/lib/nav/geom`): per map tile, the root ADT's terrain
  (heights, low- and high-res holes, area ids) and MH2O liquids, and the obj0
  ADT's WMO and M2 placements, each model's collision (WMO faces by the
  collision and render flags, indoor groups; M2 collision triangles) and WMO
  liquids, placed with the client's transform (checked against MODF extents).
- **Grid** (`tools/lib/nav/grid`, constants and file layout in `format.js`):
  2.0833 yd columns (256 per tile), walkable surfaces with 1.6 yd clearance,
  walls (model faces of 80° and steeper) stored per column side; links for
  steps, slopes (terrain to 35° plain, 35-50° and 50-60° slow, 60-80° climbed
  upward only; models to 50°), WMO staircases (treads under the upper
  column's top rising at most 1 yd walk as a slope to 45°; steps whose top
  rise is up to 1.6 yd over treads rising at most 1 yd, 2.6 yd in all, are
  a jump: the Ruins of Lordaeron's throne room into the Undercity's lift
  halls), jumps to 1.6 yd, falls (free under 14.57 yd,
  then the servers' damage share `0.018 h - 0.2426`; at most 30 yd onto
  ground and 60 yd into water; landing only on stable ground), swims and
  lifts (elevator objects of `data/forever` at their car's travel from the
  client's TransportAnimation); regions; a per-tile graph per movement class.
  Reviewed openings (`geom/openings.json`): a WMO door narrower than about
  three yards can lose to the columns when a jamb's moulding or reveal lies
  on its column centre lines, so each listed portal (by WMO and MOPT index,
  in every placement of that WMO) is cut out after rasterising: the columns
  whose centre lies within its span and `depth` yards of its plane lose
  their spans and walls from just above its bottom to its top (Brill's town
  hall, whose only way in is a 2.9 yd door into its tower room, #366).
  Fatigue water is never swum: MH2O's deep quads (the server's fatigue; a
  chunk of ocean without attributes is deep throughout, `geom/mh2o.js`)
  give a blocked swim surface (`SWIM | BLOCKED`), so no route crosses open
  sea while swims along a coast and across shallow water stay.
- **Routes** (`tools/lib/nav/path.js`, API in its header; `move.js` the
  profiles): `snap` picks the surface for a map point (without a height, a
  point over an indoor floor takes that floor or one level with it beside
  it: an innkeeper behind a counter gets the inn's floor, not its roof or
  the terrain under the city, never the storey below; then indoor floors
  before terrain, never a ledge one only falls onto), `route` minimises time for a
  character (run, swim, mounts, Travel Form, Ghost Wolf, Cheetah, indoor
  rules, slope, fall and lift costs) with a direct search for near ends and
  the per-tile graph otherwise, then smooths the path; `los` tests a
  straight walkable line. `nav path` prints one route.
- **Packing for the in-game engine** (`nav pack`, `tools/lib/nav/pack.js`,
  rules in its header): the grid as Polestar's walking maps (format 2,
  grown from Shortest Path Forever's walking-map format: his baker's HPA*
  and encodings, ported), which the router in `Polestar_Nav` (started from
  his engine) routes on. Each SPF cell (128 per tile by default, 4.17
  yd) holds the connected pieces of our surfaces in its columns (base plus
  floors; a wall inside a cell keeps its sides apart); a step between cells
  is open only where one of our links crosses it, so walls and cliffs close
  it; falls and lifts stay out (lifts are his transports), and so do
  climbs (60-80°, one way up). Faces are held back: steep links (50-60°)
  and terrain links inside a run of triangles over 50° (Forever's client
  slides off them) rising more than a jump, or on ground steeper than 50°
  measured 8 yd across. His steps are two-way and cost
  their flat length, so a face packed as steps was a straight line up it
  (Fitz's report: the Raptor Horns slope in the Barrens, 61.66,35.46 to
  61.00,33.70, where the columns and cells averaged 51-56° triangles to
  under 50°). A face link is opened again only when it is the only way
  between two parts of the map (union-find over every other join, then the
  face links gentlest first), so nothing reachable before is lost and a
  face with a way round is walked round; closing them all cut off 84 guide
  goals. A cluster (one tile) border gets his entrances, one in every run
  of crossings between the same two regions (~38 crossings at 128 cells, a
  cell crossing straight and both ways diagonally, so about every 54 yd on
  open ground), and one more on every road crossing it with none of his
  within 8 yd: every walk across a border goes through an entrance, and
  where the border crossed Ratchet's north-east road 35 yd from the main
  road with the one entrance on the main road, walks left the road along
  the border (Fitz's report, 2026-10-16). A run of dry crossings (ground
  on both sides, no swim surface) that no entrance crosses dry gets one
  too: land and the water it wades into are one region, so his entrance
  can fall in the water beside a beach or a pier's root, and walks from
  Auberdine to its boats swam 200 yd across the bay to the pier because
  the beach crossed the tile border x = 6400 with no dry entrance
  (Fitz's report, 2026-10-17). An intra-cluster edge is pruned only for a two-hop path whose
  hops are both shorter (gen_nav.py's 2% rule let two entrances a few yards
  apart at the end of a long walk prune each other's edge, cutting a region
  in two; it cut 14 guide goals off with faces held back, Thunder Bluff's
  among them). A route down a face goes round as well. Every surface
  carries a ground class byte (normal, road, gentle slope, steep slope,
  indoor, indoor city, plus an Alliance and a Horde hub bit): the steepest
  terrain triangle under it (grid format 4's tslope) against the slope
  bands, the indoor flag (indoor city where the chunk's area or the zone
  map around it is a capital, AreaTable `Flags_0 & 0x100`), the roads layer
  and the hubs layer (`nav layers`, a hub bit on every cell a hub disk
  touches); `Routing/Costs.lua` (mirrored in `tools/lib/nav/costs.js`)
  prices a step by it, per faction, and every graph edge carries its cost
  for Alliance, Horde and no profile and its yards. `chokepoints.js` adds `Chokepoints.lua`
  (`PolestarNavChokepoints[map]`: lift landings, bridges, gates at passages,
  tunnels, cave and building mouths, passes, each named by its AreaTable
  area) for the journey's named walk steps.

---

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