Toolchain
Contributor38 min readUpdated
Node ≥ 24, zero npm dependencies
On this page
node tools/polestar.js <command> [args]. Each command is a module
tools/commands/<name>.js exporting:
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.
Packs (pack mode)#
The guideOne route, written as a .guide file and compiled to Lua; its id reads like forever/alliance/human/01-elwynn-forest. commands work on a packAn addon of guides for Polestar, built by the compiler from a folder with a pack.json (the folder is a pack too); Polestar's own is Polestar_Guides (pack id guides).: 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)".
Read the rest of this section312 words
| 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 dataThe game facts in data/forever/*.json (quests, NPCs, objects, items, zones, map frames and more) that the compiler and the data builds read; built locally by data build., 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 playgroundThe real addon running in a browser against a simulated world (node tools/polestar.js play). 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 CodexLookup's database of items, mounts, pets, recipes, NPCs, rares, quests, zones, dungeons and treasures, with where each comes from (codex build, the Polestar_Codex addon). 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-extrasalso 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) anddata/forever/dungeons.json(DungeonJournal, no license). That build'smeta.jsonandMeta.luacarrylocalExtras = true, itsLICENSE-DATA.txtsays not to share it, andpackrefuses it, as it refuses a pack whose sources name any of the three (releaseRefusalintools/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 withtools/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-extrasalso<loot-db>.js(DungeonsAndRaids incl.forever.luaanddroprate.lua, Collections, Crafting, Factions, PvP) and<rares-db>.js(rares, their loot, treasures, mount and pet items). Each returns plain JSON with ametanaming 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,MountandBattlePetSpecies(the summon spells that make an item a mount or a companion), and for the dungeonsMap,AreaTable,DungeonEncounter,LFGDungeons,MapDifficulty,ContentTuning; the Classic EraItem,ItemSparse,ItemEffectandMapfill items Forever's tables lack and tell Forever's own instances apart. -
Dungeons and raids (
tools/lib/codex/instances.js, release build): everyMapinstance that Forever's dungeon finder (LFGDungeons) names, or that belongs to the original game (map id below 1000) and hasDungeonEncounterrows; Season of Discovery maps the client also carries stay out.forever= absent from the Era client'sMap. Levels[entry, min, max]: theMapDifficulty"at least level N" message, theContentTuningminimum of its dungeon-finder entries, the highest boss level (at most 60). Bosses: theDungeonEncounterrows of the normal difficulty inOrderIndexorder, each matched tonpcs.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: itsitems.jsondrops 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'scommondroprows (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 (RecorderThe optional Polestar_Recorder addon that records play; record import turns its sessions into data/recordings.enter,data/forever/instances.json), else the curated tabletools/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'sgoto x,y "<instance name> ...", else theMapCorpse 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, markedapprox: true(packed as a fifth entrance fielda, read asCodex.Instance(key).entrance.approx).AreaTriggerrows 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.jsoninstances, from the community quest database and recordings, or the web database'slocationnaming anAreaTablearea 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-extrastakes the instances from the loot-table addon anddungeons.jsoninstead. -
The Forever web database per entity (
tools/lib/codex/<web-db>.js, release build only, lowest precedence).codex buildreads the Forever web database facts cached underdata/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,locationandenvChange, how Forever differs from Classic) and each fetched entity's facts (<npc|object|item|spell>/<id>.json).codex buildreads 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'slocation(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 arole: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), elseoptional; 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 anobjectand nonpc(roleoptional). A boss is{ name, npc, items }, plusobjectandrolewhen set; the pack writes its ref as the npc id, oro<object>, thenr/e/xfor 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 tabletools/lib/codex/attunements.jsonnames per Codex instance each attunement's goal only (the quests that end it, one of them withany, 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 ofpre, thepreAnyof the entry's side), oldest first, each withafter/afterAny; a quest only an item starts gets that item's drops before it (Blackhand's Command from the Scarshield Quartermaster); an item goalOne thing to do in a step, as a verb and its arguments (accept 783, kill, goto, turnin...). 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).linksadd 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).insideplaces 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 asinside: [{ kind, id, instance }].removedlists what Forever took out, with its evidence (the Seal of Ascension: 4743 no longer rewards it, no ForeverItemSparserow), 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 buildprints it); an instance the build lacks has none. The journal puts each list on its page asattunementand counts them (meta.counts.attunements, the summary'sattune = { 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 indata/local/attunements-verdicts.mdso 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 thechestrole, 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 theirfromprovenance and the best-known drop chance. -
Output:
meta.json(sources,localExtras,countsby 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(withpre= every blocking prerequisite,preAny, rewards from recordings and the leveling gear lists,xp= the full XP reward fromdata/forever/quests.jsonwhere 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'sstatusisnosourcewithout a source, andretiredfor 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'sMountrow the Forever client flags hidden until known (Flags0x40: 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'scollectskeeps 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-extrasbuild indata/local/codex-extras(when there is one), writesdata/local/codex-parity.jsonand.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 itsmeta.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'sevidence.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 databaselocationor 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 samedata/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
Maprow); 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 notDungeonEncounterrows; reputation standings that vendors gate in gossip, and battleground rewards the loot-table addon files as PvP, are in no client table (ItemSparsecarries noMinFactionIDorRequiredPVPRankfor them, and the Forever web database's Sells standing is that sameMinReputation). A vendor's, giver's or quest's gate comes from the cited tabletools/lib/codex/repgates.json(loadRepGateTable, provenancecurated: 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 itsbuy(curatedRepGates, provenanceguides: the Winterspring Frostsaber, the Stormpike Battle Charger, the Frostwolf Howler), and a quest's from the community quest database (minRepor a reputation objective, the higher: arepsource naming no vendor, and the quest'srepFaction/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 itsrepFaction/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 inItemSparse). An NPC that casts a spell creating an item (clientSpellEffect24; who casts it, the Forever web database's spell page in the cache) hands it out: agift { 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 simulatorThe route model and search that finds and scores leveling routes (node tools/polestar.js sim, SIMULATION.md)., 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.
Read the rest of this section825 words
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 hubPolestar's main window: a rail of sections (Guides, Lookup, Dungeons, Rares) with Options at its foot (UI/Picker.lua).'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 stepOne stop in a guide: a title, optional conditions and tip, and at least one goal. 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>": { id, name, type: "party"|"raid", entrances: [ { map, x, y, n } ],
encounters: { "<dungeonencounter>": { 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:
- The Era quest database (MIT, git clone cached in
data/sources/<era-db>): Classic Era quests, npcs, objects, items, drop chances, Era-frame coordinates. - The community quest database's Forever store (
<quest-db>-forever): the<quest-db>_Forever.tocthat a community quest addon for Forever installs.--<quest-db> <dir>, else auto-detected in the Forever client the wayinstallfinds it (client detection lives intools/lib/addons.js:detectAddonsDirs,foreverKind). It's read in place (tools/lib/data/<quest-db>.jsports the community quest database'ssrc/meta/codec.lua,src/read/baked.luaandnormalize.default). Field layouts come from the installedsrc/meta/*Meta.lua, and support data fromsupport/Forever/*: QuestXP, DropTables (chance = manual correction, else<server-db>, else the web database, asDropDB.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, npcroles, 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: npcfactionstays the Era quest database's, because the community quest database's friendlyToFaction means "not hostile", so wildlife reads "AH".prealso 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 indata/, and only the importer is in this repository. - 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 fromdata/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'ssourcegains<web-db>:xp,<web-db>:level,<web-db>:minLevelor<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. - 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 anote: 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'ssourcegainspolestar-correction. One cleanup is general rather than per quest:pre,preAllandpreAnynever contain the quest itself or an id from itsexcl. The community quest database preQuestSingle sometimes lists the whole exclusive group. - 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.
Read the rest of this section518 words
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 } } }Read the rest of this section392 words
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
/reloadcosts 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 ForeverAreaTableContinentID when thatMaprow is InstanceType 1 (party) or 2 (raid); without theMaptable (an optional Forever table), an area no UiMap shows on a continent other than 0 and 1. Recordedinstfields 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
openand kept out of the statistics. -
XP from the snapshots, as cumulative XP (recorded
xpMaxper 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,cthe instance map), map percent per frame (f = "e"Era, else live), and the renderer's planeX = -wy,Y = -wx. - Map art (
planner art,tools/lib/planner/art/): the Forever client's minimap blocks (WDTMAID, 8th field; ADT tile(col, row)=map<col>_<row>.blp= WDT entryrow * 64 + col) and zone map art (UiMapArtTile plus every WorldMapOverlay), fetched by fileDataID from the game-table export intodata/sources/casc/<product>/<build>/, built intodata/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 overtravel.jsonlegs at 30 yd/s, boats with their waits, hearth with its cooldown,levelOverheadper level) andtools/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 explicitzoneline 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 asPOST /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 overGET /api/events(SSE), as are external changes (fs.watch). A full lint runs in a worker thread 600 ms after changes;pack_compilewrites the addon folder ascompiledoes. 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.jsprofileFor: mount frommountLevel, 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 recordsroute { 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 countlegs,routed,hops,hopsRouted,routeSecondsandestimateSeconds. Flights keep their time and, withflightPaths(the Era client's TaxiPathNode points of each travel.json taxi leg, read from the cacheddata/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 aworker_threadspool (one Nav per worker; CPUs - 1, at most one per 4 GiB) whiletimeline_getanswers at once with the straight legs androuting { pending, done }(SSEroutingeach second); when they are all in, the cached timeline is dropped and SSEchanged { timeline: true }makes a page whose timeline is still being routed ask again (as does an SSE reconnect, and, when noroutingevent for its timeline arrives within 10 s, the page itself). Routes are appended todata/local/nav/routes-<build>.jsonl,buildbeing a hash of the grid'sindex.json,lifts.jsonand 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_routeroutes one walk for an agent;GET /nav/index.jsonlists the grid's tiles,GET /nav/tile/<c>/<col>_<row>.pngdraws an ADT tile's Walkable layer (per column: nothing walkable, steep, climb, swim only, indoor only; plain ground clear), andGET /nav/aids.jsonserves 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 informat.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 share0.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 ofdata/foreverat 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 anddepthyards 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.jsthe profiles):snappicks 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),routeminimises 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;lostests a straight walkable line.nav pathprints 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 inPolestar_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, AreaTableFlags_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 intools/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.jsaddsChokepoints.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.