Nav
Contributor66 min readUpdated
The Nav API, map canvas and pins, route lines, the travel planner and the in-game walking routes.
On this page
Polestar.Nav.SetTarget(map, x, y, label, f, stop, z) -- user picked a goal; overrides the engine waypoint until the step changes;
-- stop: what stands there ("dungeon", "trainer", ...), for the hand-off below;
-- z: the height of the NPC or object there, optional (the walk ends on its floor)
Polestar.Nav.ClearTarget()
Polestar.Nav.GetTarget() -> waypoint | nil
Polestar.Nav.GetDistance() -> yards | nil -- to an area target's edge, 0 inside itSettings live under nav.*; nav.distanceUnit ("yards", the default, or
"metres") sets how distances read, and the arrow redraws its distance as
soon as it changes. Nav hides itself in instances, on taxis, and when
the target is on another continent (shows the zone name instead). Where the
client gives no player position (the Deeprun Tram is a map of its own, Map
369, with no UiMap), Nav.Sample returns "instruction" while the route's
current leg is a ride, flight or hearth, so the arrow keeps that leg's glyph
and caption ("Take the tram to Stormwind"), and "nopos" otherwise: the arrow
says "No position here" over its label. It points again as soon as a
position returns.
Read the rest of this section429 words
An area target (a waypoint with area, yards: a spawn area) is reached
anywhere within area + r of its centre; outside, the distance shown is to
the area's edge. A camp then shows the arrived state with the goalOne thing to do in a step, as a verb and its arguments (accept 783, kill, goto, turnin...). label. An
area objective (search) never says arrived: Nav.Sample returns "search"
and the arrow says "Search here", the goal label, and "Click the arrow for the
next spot". A left click on the arrow (not a drag) calls
Nav.NextSpot([index]), which asks the Engine (CycleSpot) for the next spot
while the target is the Engine's waypoint with spots. A guideOne route, written as a .guide file and compiled to Lua; its id reads like forever/alliance/human/01-elwynn-forest. goal's area
shows as its spawn regions (below); any other area target's nav.target pin
carries the area, so the world map and the minimap draw the area's soft disc
under the target pin. The nav.spots layer (world map, under nav.worldPins)
marks every other spot of the target as a small pin (an alt spot smaller and
fainter, its tooltip "It also spawns here"); clicking one searches there next
(Nav.NextSpot(i)). TomTom's arrival distance is area + r.
Every place the current stepOne stop in a guide: a title, optional conditions and tip, and at least one goal.'s open goals spawn is a region on the map
(Layers.lua, read from Engine.GetGuide().steps[step].goals[i].spots for
the goals GetStepView shows active or pending). Layers.Regions(goal)
groups a goal's spots once per goal (cached by the compiled goal table): each
spot's disc (its radius covers its spawns, at least 15 yd, plus 10 yd) joins
the spots whose discs come within 90 yd, nearest first, while the region stays
at most 700 yd across (a coast of spawns is a chain of regions, not one hull
over the sea). A region is the polygon whose sides touch the convex hull of
its discs from outside (16 sides, 8 under 200 yd), so it covers every spawn
of its spots; alt when all its spots are. nav.areas holds the target's
goal's regions as hull pins in the arrow's colour (alpha 1, alt 0.6), on
the world map and the minimap (nav.worldPins, nav.minimapPins);
nav.goalAreas the step's other goals' regions in place at 0.6 (alt
0.35), on the world map only. A point picked by hand (SetTarget) leaves
every goal's regions in nav.goalAreas. The layers are set again only when
what they show changed (step, open goals, the arrow's goal, colours,
surfaces), so route and progress updates redraw nothing.
While a SetTarget override is active the Engine keeps announcing its own
waypoint; Nav.GetTarget() is the point actually being guided to. Nav fires
WAYPOINT_CHANGED when it sets or drops an override. ClearTarget hides the
Engine's waypoint until the Engine announces a different one or the step changes.
Nav API — journeys for players and other addons#
Nav/Journey.lua and Nav/Nearby.lua (adapted from Shortest Path Forever by
Cillian Berragan, GPL-3.0-or-later, with permission). Every target they set
is a Nav.SetTarget override, so it is routed (and timed) like any goal and
dropped when the guide's step changes.
-- Public, for other addons. Points: a uiMapID and x, y as 0-1 fractions (as TomTom and C_Map).
Polestar.Nav.API.version -- 1
Polestar.Nav.API.Estimate(fromMap, fromX, fromY, toMap, toX, toY) -> seconds | nil, "invalid" | "unknown" | "busy"
-- the travel planner's estimate (Nav.EstimateTravel: flights, boats, timetables, cached a few
-- seconds per start cell and destination), else a straight run on one continent; fromMap nil:
-- from the player, the Hearthstone counted. An uncached call runs a full route plan
-- (up to its 40 ms cap). Once a frame has spent 25 ms planning estimates (Polestar's own
-- included) no new plan starts for the API and the answer is nil, "busy": ask again next frame
Polestar.Nav.API.Navigate(owner, map, x, y, title) -> true | false -- owner: a non-empty string
Polestar.Nav.API.Cancel(owner) -> true | false -- that owner's target, while it is the one shown, lets go
Polestar.Nav.API.Active() -> active, owner -- anything guided to; owner while an API caller's target shows
-- Internal
Polestar.Nav.journey.PlanTo(map, x, y, label, f) -> ok -- onto the zone under a continent spot; refuses open sea
Polestar.Nav.journey.Estimate(from, to, budgeted) -> seconds | nil[, "busy"] -- from, to = { map, x, y, f } (percent); from nil: the player; "busy" only when budgeted (the API)
Polestar.Nav.nearby.Find(kind, skillLine) -> place, seconds | nil, "kind" | "codex" | "combat" | "none"
Polestar.Nav.nearby.Go(kind, skillLine) -> ok -- weapon: skillLine is the weapon skill's line, or nil for any
Polestar.Nav.nearby.Trip() -> { target, name, label } | nil -- the trip Go started, while its target is the one shown
Polestar.Nav.nearby.EndTrip(arrived) -> ok -- back to the guide: the trip's target lets go
Polestar.Nav.nearby.Weapons() -> { { line, label, known, level }, ... } -- the weapons the class can use that a master of its side teaches
Polestar.Nav.nearby.MenuItems() -> items -- the guide window button's list (vendor first; a weapons page)
Polestar.Pins.MinimapCursor() -> dNorth, dWest | nil -- yards from the player to the cursor on the minimap- Plan to anywhere (
nav.mapPlan, on): Shift-click empty space on the world map (a canvas click handler, so it takes the click before the map zooms) or the minimap; Alt-click anywhere on the world map, its icons included (GLOBAL_MOUSE_DOWN; the icon's name labels the target), except Polestar's own clickable pins. The label is "Zone x, y". Shift- or Alt-clicking within 1.5% of the map's width (8% of the minimap's radius) of the shown pick, or Shift-clicking its pin (Pins.OnWorldClick; Polestar's pins are its own frames,Pins.WorldPinOftells them under the mouse), lets it go. With a chat edit box open Shift-click stays Blizzard's (links into chat). A spot on no zone (open sea, the world map, the minimap's corner) prints one line and plans nothing. - Ownershipa target replaced by another is not the owner's while it is
away; the same spot and label set again (the death helper hands a picked
point back once you are alive,
QoL/Death.lua) is theirs again. - TomTom stand-in (
nav.answerTomTom, off): while TomTom is neither loaded nor enabled in the AddOns list, the globalTomTomanswersAddWaypoint,AddMFWaypoint,AddZWaypoint(0-100),AddWaypointToCurrentZone,RemoveWaypoint,ClearWaypoint,ClearAllWaypoints,SetCrazyArrow,IsValidWaypoint,WaypointExists,GetDistanceToWaypoint,GetKeyandSetClosestWaypointthroughAPI.Navigate("TomTom", ...), one waypoint at a time;corpsewaypoints (the death helper's job) andfrom = "Polestar"ones are refused. Any other method name with an upper-case first letter (TomTom's style) is a no-op returning nil, so an addon calling more of TomTom's API never errors; fields (TomTom.db, profiles) are not provided and read as nil. The global is taken at login, once every addon has loaded, so an addon bringing its own TomTom-compatible global keeps it; it is given back (cleared) when the setting goes off or TomTom loads.Nav/Bridges.luanever mirrors into it, nor into Shortest Path Forever's own stand-in (the global while it is installed and TomTom is not): a waypoint there starts a journey of its own, which Polestar steps aside for (#306). Default off: the stand-in has methods only, so an addon that finds the global and reads TomTom's settings (TomTom.db, its profile) would error; the option ("Let other addons set waypoints", Options, Integrations, TomTom) says so. With it off nothing setsTomTomand/wayis not claimed. - Following TomTom (
nav.followTomTom, off; real TomTom only): hooks on TomTom's table:SetCrazyArrow(uid)points Polestar's arrow at that waypoint (notcorpse, notfrom = "Polestar"),RemoveWaypointandClearWaypointof it let it go ("Remove all waypoints" clears throughClearWaypointalone). Waypoints TomTom restores at login are followed one frame afterPLAYER_ENTERING_WORLD, the one its arrow was left on, unless something is picked already. A followed waypoint is not mirrored back. /way(withnav.answerTomTom): claimed at login (SLASH_POLESTARWAY1) only when TomTom is not loaded and noSLASH_*global spells/way./way [#mapID | zone] x y [title]: the zone is any name under the player's world, exactly, the one zone it begins, else the one it is part of ("barrens"); several print their names./way resetclears.- Nearby services (
/polestar near <kind> [profession];nav.nearbyMenuadds a "Nearby services" submenu toMENU_WORLD_MAP_TRACKINGandMENU_MINIMAP_TRACKING): kinds trainer (profession, by skill line, or any), class, weapon (a weapon skill's line, or any), repair, reagents, vendor, innkeeper, bank, auction, flight, stable. Weapon masters are the NPCs ofWEAPON_MASTERS(the weapon skills each teaches, noted by hand) of the player's side. Places are 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).'s NPCs with that role (Codex.WithRole, loaded on demand, never in combat), friendly to the player's faction and outside instances; reagents are vendors listed inQoL.data.reagentVendors(qol build: a vendor whose subtitle names reagents); the class trainer isEngine.NearestClassTrainer(). The nearest spawn of each NPC by straight line (the player's continent first), the six nearest of those estimated byJourney.Estimate, the quickest becomes the target and one chat line names it, its zone and the time. - The guide window's button (
nav.nearbyButton, on: Options, Guide, Arrow & routes): an icon left of the bug, Skip left of it (left of the bug when it is off). Its menu isNearby.MenuItems(): the services, a trainer per profession the character has, and Weapon master, a page ofNearby.Weapons()(a known weapon, byCompat.IsPlayerSpellon its skill's spell, and polearms before level 20 are listed and off); off in combat. - The trip back to the guide.
Gostarts a trip (Nearby.Trip()) and firesNEARBY_TRIP(trip).NavfiresNAV_ARRIVED(target)once per arrival; on the trip's target,EndTrip(true)releases it and the arrow is back on the guide's step. Any other target (a goal clicked, a map click) ends the trip quietly, andNEARBY_TRIP(nil)follows each end. On the way, the guide windowThe small window that shows the current step and its goals while playing (UI/GuideFrame.lua). shows a banner and the button's list a first entry, both "Back to the guide" (EndTrip()).
Hand-off to Shortest Path Forever#
Nav/Handoff.lua (Nav.handoff; loaded after the other Nav modules). With
Shortest Path Forever (opens in a new tab)
installed (its global ShortestPathForever.API, version 1, with Cancel
and NavigateRoute or Navigate; every other function checked before it is
called, as its minor grows) the two never guide at once:
- Hand-off (
nav.spfHandoffon; an option, off by default since 0.2.5: Options, Integrations, Shortest Path Forever: "Hand guiding to Shortest Path Forever", shown only with it installed): Polestar's target is guided by it and Polestar's own guidance steps aside. - Yield (the option off, the default): Polestar guides, and steps aside
exactly as it does when handing off while Shortest Path Forever runs a
journey that is not Polestar's (
Active()true and noCurrentStop("Polestar"): one the player started there, or another addon's). It looks onSubscribe's news (else on Nav's once-a-second tick), atPLAYER_ENTERING_WORLDand when the option is turned off; never in combat (the news waits forPLAYER_REGEN_ENABLED). When that journey ends Polestar guides again (Nav.Reapply(), the TomTom stand-in with it).Nav.SayHandedOff()then says "Shortest Path Forever is guiding a journey of its own. Polestar's arrow comes back when it ends." A journey of Polestar's left running in it from 0.2.4 (which handed off by default) is cancelled once with the option off, at the firstPLAYER_ENTERING_WORLD(in combat, when combat ends; until then Polestar steps aside as if yielding, since that journey may still guide).Subscribeis called atPLAYER_ENTERING_WORLDand when the option changes, never in combat (then once combat ends); turning the option off in combat cancels Polestar's journey there once combat ends.
Read the rest of this section814 words
Polestar.Nav.HandedOff() -> bool -- the one predicate: handing off (installed, on, the last stop not refused, and it guides that stop), or yielding
Polestar.Nav.Reapply() -- (Nav.lua) re-plan and tell every module the same target again, unannounced
Polestar.Nav.handoff.Available() -> bool
Polestar.Nav.handoff.Yielding() -> bool -- the option off and a journey not Polestar's runs in itWhat steps aside
Nav.HandedOff(): the arrow hides
(Arrow.lua); the travel planner plans no route (Travel.Replan), so there
are no route lines, journey steps (UI/JourneyPanel.lua), travel button
(Nav/Prompts.lua) or leg alerts; the trail is cleared (Layers.lua);
transport alerts are off (Alerts.Check); Nav/Walk.lua neither loads
Polestar_Nav (Data.Pack is never called) nor searches; Shift/Alt map
clicks plan nothing, the TomTom stand-in is not installed and /way is not
claimed (Nav/Journey.lua, at login and when the setting changes; the
addon then claims its own); Nav/Bridges.lua mirrors into neither TomTom
(its stand-in would start a second journey) nor Blizzard's user waypoint.
The guide window, the target pin, spawn regions, every other pin and the
Engine's own arrival checks stay. When the predicate flips,
Nav.Reapply() brings each surface back or sends it away again.The stop
NavigateRoute("Polestar", { stop })
(Navigate with title and kind when only that exists): the target's live
map point (Nav.LiveCoords, as 0-1), its label as the title, and a kind:
the hand-set target's stop (Dungeons' entrances, Nearby's trainers, inns
and flight masters, the trainer banner), else the goal's verb (accept
pickup, turnin turnin, kill/collect/use/interact/complete
objective, train trainer, sethome innkeeper, getfp/fly
flightmaster), else the place it stands at from the travel data (a
transport landing within 30 yd: its boat, zeppelin, lift or tram; a portal,
flight master or inn within 15 yd). A kill, collect, use or interact
waypoint with a spawn area is held: hold = true, radius = area + r,
shapes = every spot of the goal as a circle (spot radius + r, live
frame), questID the goal's quest and questIDs too when other open
objectives of the step (GetStepView) have spawn spots overlapping it, so
the stop never arrives and directions pause inside the area.When it is sent
PLAYER_ENTERING_WORLD, only when the stop differs from the last one taken
(every field compared), so re-announced targets, ticks and settings never
churn. In combat, without a position or in an instance nothing is called and
the stop waits (PLAYER_REGEN_ENABLED, PLAYER_ENTERING_WORLD,
ZONE_CHANGED_NEW_AREA); a call refused because combat began waits the
same way. No target: Cancel("Polestar") while its journey runs. Dead or a
ghost (UnitIsDeadOrGhost): no call at all, as it guides corpse runs
itself; alive again the target goes as it is (the same stop is not resent).Not fighting
Subscribe (else Nav's once-a-second tick) reads
Ended("Polestar") and CurrentStop: once the journey arrived or was
cleared or replaced it is left ended, nothing is resent and no Cancel
is called, until Polestar's target changes.Refused
Nav.HandedOff() false) and cancels its earlier journey; the next
target, or a zone change, offers a stop again, and the first one taken hands
back.No way there
CurrentStop("Polestar") set but Active() false: its search found no
way, StopGuide in its Journey.lua, the tracker saying "No way there
from here."; or its guidance turned off in its tracker): Polestar guides
that target itself (Nav.HandedOff() false), leaving its journey in place
and sending nothing. It is read on Subscribe's news (else Nav's tick) and
at death and coming back; it is never set while dead or a ghost (its corpse
run). Polestar goes on guiding after that journey ends, and steps aside as
soon as it guides anything again (that journey, the player's own, another
addon's, its corpse run); the next stop taken hands back.Off
nav.spfHandoff off cancels the journey and Polestar
guides again (Polestar_Nav loads then), unless a journey not Polestar's
runs (yield, above); on again sends the stop anew.Options and commands
Nav.HandoffOption(control) wraps every option
that has no effect while handing off: its disabled also answers
Nav.HandedOff() and its help ends "Greyed out while Shortest Path Forever
guides: it does this itself." (the arrow, its arrival time, chime, look and
position; travel routes, walking paths, route lines, class teleports, trail
dots, journey steps, the travel button, transport alerts; map clicks, other
addons' waypoints, the Blizzard and TomTom mirrors and TomTom's arrow).
Distances, boat-time sharing, map pins, following TomTom and the nearby
menu still act and stay. Nav.SayHandedOff() prints one line ("Shortest
Path Forever is guiding. Turn off "Hand guiding to Shortest Path Forever"
...", or the yield's line above) instead of /polestar arrow show
(or the bare toggle turning it on), /polestar route and
/polestar skipleg. Polestar's arrival chime is silent meanwhile.Tested by tests/nav-handoff.test.js against a fake API written to its
docs/api.md (v1.16.0).
MapCanvas — drawing on Blizzard's map frames#
local view = Polestar.MapCanvas.Watch(map, fn) -- map: WorldMapFrame or BattlefieldMapFrame; one view per map frame
-- fn(view, what, elapsed): what = "show" | "map" | "size" | "scale" | "alpha" | "hide" | "tick"
-- view.map, view.canvas, view.shown, view.mapID, view.width, view.height, view.scale, view.zoom, view.pinScale, view.alpha
view:Level(levelType) -- the frame level the map gives its pins of that level type
view:PinScale(startScale, endScale) -- a map pin's scale under SetScalingLimits(1, startScale, endScale)
Polestar.MapCanvas.Get(map) -> view | nilThe world map and the battlefield map are Lua (Blizzard_MapCanvas,
Blizzard_WorldMap), and Forever's gamepad interface reads them from secure
code as they open and close (MapCanvasMixin:OnHide walks the pins its gamepad
cursor hovered, then FrameControlsManager refreshes its focus and
Blizzard_GamepadActionBars calls the protected
SetPreferredGamepadInteractTarget). A value an addon wrote into the map's
tables taints that code and the game blocks the call ("An action was blocked
because of taint from Polestar"). So every module draws on the maps through
Nav/MapCanvas.lua and never writes into them: no data provider, no pin
pool, no AcquirePin, no map method that writes, no hook on the map. Its
frames are children of map:GetCanvas() (parenting is the client's, in C:
the scroll frame clips them, they show and hide with the map), each made
with no parent and then set under the canvas (CreateFrameUnder, Core,
"Frames under the game's UI"), positioned
from map percent times the canvas size, and the map is only read through
GetMapID, GetCanvas, GetCanvasScale, HasZoomLevels,
GetCanvasZoomPercent, GetGlobalPinScale, GetGlobalAlpha,
GetPinFrameLevelsManager():GetValidFrameLevel(levelType) (each returns a
field or works one out, writing nothing; not GetViewRect, which caches) and
the canvas's GetWidth / GetHeight; Maps' info bar also reads
GetNormalizedCursorPosition, IsCanvasMouseFocus and ScrollContainer
(to sit at its foot), and Pins the canvas's GetLeft, GetTop and
GetEffectiveScale and the gamepad cursor's place (SoftCursor:IsShown,
GetCenter, GetEffectiveScale). Frames on the canvas carry no field the client's code
reads on the frames it finds there (owningMap on the mouse focus,
smartNavigation* and the other SmartNavigation keys on the buttons it
scans), and none is a button to the gamepad's navigation: as the gamepad
interface focuses the map, SmartNavigation_CanFocusFrame takes a Button,
an EditBox or a frame with an OnMouseDown or OnMouseUp script for one and
reads that script back (GetScript), which put Polestar's function in its
secure code (seen in game: the map's FocusGamepad registered its callbacks
tainted). Polestar's world map pins take the mouse with OnEnter and OnLeave
only; a click is a GLOBAL_MOUSE_DOWN and a GLOBAL_MOUSE_UP of one button
(not the right one, which passes through to the map) on the pin. A view is a frame of Polestar's on the canvas whose OnUpdate runs
only while the map is shown; each frame it compares the map ID, the canvas
size, scale and zoom, the pin scale and the alpha, and tells its listeners
what changed ("show" with fresh values on the map's first frame open).
tests/nav-mapcanvas.test.js runs every drawing with the maps' tables
guarded (stub.guardMap: any write of addon code fails), pins the list
of what is read and checks that no frame was made with a parent on the maps
or the minimap.
Pins — map and minimap icons for every module#
Polestar.Pins.SetLayer(layer, pins, opts) -- replace a layer; opts = { world = true, minimap = true, edge = false, scale = 1,
-- minimapAreas = false }
Polestar.Pins.ClearLayer(layer)
Polestar.Pins.SetLayerVisible(layer, visible)
-- pin = { map, x, y, f, icon (Theme.Media name or texture path), size, minimapSize, color = { r, g, b, a },
-- tooltip = string | function(tooltip), onClick = function(button), priority,
-- glow (soft halo), ring = { r, g, b } (ring round the disc), backdrop (scrim disc), area (yards),
-- hull = { wx1, wy1, ... } (a convex region in world yards on the pin's continent `c`),
-- areaOnly (only the area or hull draws, no pin) }Pins are drawn on the world map for the map being viewed (converted through Compat
for other maps) and projected on the minimap; edge = true clamps that layer's
out-of-range pins to the minimap rim. On the world map they are Polestar's own
frames on the canvas, at the frame levels the map gives its own pins (MapCanvas,
below): pins at PIN_FRAME_LEVEL_SUPER_TRACKED_QUEST, counter-scaled as a map
pin with SetScalingLimits(1, 1, 1.25) is, tooltip and click, the right button
passed through to the map (SetPassThroughButtons, restricted in combat: set
once per frame, for frames made in combat once it ends). The gamepad
interface's map cursor (SoftCursor, no mouse) hovers them as the map hovers
its own pins: every 0.05 s while it shows, the nearest pin with a tooltip or a
click within 12 UI units (its place from its map percent and the canvas's
GetLeft, GetTop and GetEffectiveScale) shows its tooltip, and a press of
A (GAMEPAD_FACE_BOTTOM, polled with IsKeyDown so the press still reaches
the map) clicks it; a redraw hands every
frame back the pin it showed, higher priorities on top; layer changes while the
map is open are drawn once, on its next frame (GetWorldFrames(kind) runs a
pending draw first). Pooled frames, distance-filtered, minimap
updates throttled; layers with hundreds of pins must cost nothing per frame when
the player stands still. Each layer indexes its pins by continent and 500-yard
grid square when it is set, so a minimap redraw visits only the pins around the
player; layer changes made in one frame share one minimap redraw on the next
(GetMinimapFrames runs a pending one first). Nav's own target pins are one layer. A pin's area
draws a soft disc of that radius (Media/Nav/area, the pin's colour at 0.32
alpha) under every pin on the world map (PIN_FRAME_LEVEL_QUEST_BLOB, frames
of their own, no mouse, sized in canvas units so it scales with the terrain). On the
minimap only a layer with minimapAreas draws its areas (Nav's target and
nav.areas; quest objective areas stay on the world map): textures on one
no-mouse frame under the trail and the pins, every area whose disc reaches
into the view (the layer's grid is searched out to the view plus its widest
area), sized 2 × area × the minimap's pixels per yard, and masked to the
minimap's shape (a round minimap: Media/Nav/pin-fill's disc over the
minimap's width; a square one: a plain square), so a disc is cut at the rim.
A client without mask textures draws only the discs that fit inside.
GetMinimapAreas(layer) lists the drawn discs (tests).
Read the rest of this section575 words
A pin's hull is a region: a convex polygon cut from solid textures
(WHITE8X8) whose quads SetVertexOffset bends into place, with pixel
snapping off, so pieces that share an edge are rasterised without a seam or
an overlap. The polygon inset by the rim is filled with quads fanned from its
first point (the pin's colour at 0.2 alpha), the rim is one quad per side
(0.7), 1.5 UI units wide on screen. Every piece shares the polygon's box
around its centre. On the world map a hull is a frame of its own
(PIN_FRAME_LEVEL_QUEST_BLOB, no mouse, the box in canvas units; the rim is
laid out again when zooming changes its width by a quarter), drawn when any of
it lies on the viewed map (the view's world-to-percent transform, the trail's).
On the minimap a minimapAreas layer's hulls sit with the discs, masked the
same way; a hull is laid out again only when its pin, the zoom, the minimap's
size or (rotating) the facing changed, and walking moves only the first
piece, which the others are anchored to. GetMinimapHulls(layer) lists the
drawn hulls with their outlines (tests); AreaStats() counts hulls, pieces
and discs per surface (in game: /dump Polestar.Pins.AreaStats()).
Polestar.Pins.SetTrail(runs, opts) -- runs = { { live = true?, { c, wx, wy }, ... }, ... }; opts = { world, minimap, color }
Polestar.Pins.ClearTrail()
Polestar.Pins.TrailDots(points, spacing, phase, rect, cap) -> count, positions -- pure placement (tests)The trail: animated dots along straight lines between consecutive points
(Nav publishes the travel route: the player, then each leg's start where the
mode changes and its end, a new run after a Hearthstone; without a route,
player -> target). A live run starts at the player's current position. Runs
break where the continent changes. Dots are anchored to each run's end, so
they stay put as the player walks and crawl toward the destination (one
spacing per 1.5 s; still with ui.reduceMotion). Minimap: every 40 yd, only
inside the visible disc or square, 5 px, at most 40. World map: every max(15,
map width / 60) yd, clipped to the map, 6 px (counter-scaled like the pins)
under the pins, textures on one no-mouse frame, at most 300. One 20 Hz driver runs only while dots show.
nav.trailDots (default on) with nav.worldPins / nav.minimapPins per surface.
On OpenMap's stitched world map (OpenMapAPI, OpenMap's docs/api.md; an
optional dependency), the world map frame shows only the map under the view's
centre while OpenMap draws the rest, so the trail goes through OpenMap's own
placement instead: each run's continent yards to the shown map's percent through
a reference map on that continent (the shown map, or on the world map its
children), placed by OpenMapAPI.WorldFromMap / MapFromWorld; clipped to what
the map's scroll frame shows (and 4% past it), not to the map; every max(15,
view width / 60) yd rounded up to 15 yd times a power of two, so dots keep their
places through a zoom within a step; redrawn when a pan or zoom moves the view
(the canvas's and its scroll frame's place, read each frame) and when OpenMap
switches its map on or off (OpenMap_StitchedChanged). nav.openmapDots
(default on) is a switch of Polestar's in OpenMap's menu; it hides the dots on
OpenMap's map only. Only OpenMap's table is called; the map frame is only read.
The trail stands in only while route lines draw nothing (nav.routeLines off):
Nav/Layers.lua clears it while Polestar.RouteLines.Active().
Route lines (Maps/RouteStrokes.lua, Maps/RouteLines.lua)#
The way to the target on the world map and the minimap, from
Nav.GetRouteGeometry() (legs from the current one: { kind, pts = { { c, wx, wy }, ... }, current },
NAV_ROUTE_GEOMETRY on change). The geometry and the painters are ported
from Shortest Path Forever's UI/Strokes.lua, UI/FlightLines.lua and
UI/Route.lua (v1.15.0, Cillian Berragan, GPL-3.0-or-later, with his
permission; addon/Polestar_Nav/CREDITS.md).
RouteStrokes.luais pure: draw paths and a view in, strokes out, into a buffer of parallel arrays rewritten in place (no allocation per draw). Walks are breadcrumbs (Media/Nav/pin-fillon a line one dot long over a dark one): 4 px every 9 px on zone maps, 3 px every 7 px on continent maps, the spacing carried across bends, none within 2 px of a stop's pin (the route's stops and the target). Flights follow their tracks (Maps/FlightPaths.lua,travel build: TaxiPathNode simplified to 10 yd), corners cut three times, flight masters kept as the ends; a flight leg's points are matched to taxi nodes by position. Boats and zeppelins between two docks bow gently off the straight line; the tram, lifts and the rest are straight 2 px strokes over a 4 px outline. Hearthstone, teleport and portal legs draw nothing (their pins mark them). Legs after the current one are at 40%. Lines are clipped to the map (segments, not points) and to the minimap's disc or square. Every buffer is bounded (4096 strokes; the minimap's 600), so the pools of lines, which the client cannot destroy, are too.RouteLines.luabuilds the draw paths once perNAV_ROUTE_GEOMETRYand paints: one frame of Polestar's on the world map's canvas (MapCanvas, Nav), the canvas's size, atPIN_FRAME_LEVEL_QUEST_BLOB, no mouse, redrawn on map, zoom, size, route, setting and theme changes, and one frame over the minimap under Pins' frames, redrawn by a 10 Hz driver only when the player, facing, zoom or route changed (Pins.MinimapView()for the view). The walk being walked starts at the player (the nearest point ahead within 60 yd; progress only moves on). Colours are Theme tokens per kind, as the travel pins': walkarrow, flightsuccess, boatnpc, zeppelinplace, tram and liftobject; outlinesscrimat 50%.nav.routeLines(default on) withnav.worldPins/nav.minimapPinsper surface.- Line style (
Maps/RouteSparkle.lua,UI/LineStyle.lua):nav.lineStyle"dots"(the default, the above) or"sparkle", set in the window the gear beside "Route lines" opens (Options, Guide, Arrow & routes; the gear isW.Toggle'sopts.gear). Sparkles (ported from Forever Sparkle Trail) sit along the same draw paths, measured in yards and split by continent: evenly spaced on screen at any zoom (nav.sparkleDensity, 36 px at 1 to 9 px at 10), each aMedia/Nav/sparklestar overMedia/Nav/glow, additive and tinted (nav.sparkleColor: loot gold and four others, or each leg's dot colour), twinkling on its own hash and drifting toward the goal (nav.sparkleFlow);nav.sparkleIntensity,nav.sparkleSubtlety,nav.sparkleSize,nav.sparkleMax(per surface) andnav.sparkleLater(the legs after this one, at 40%), with three presets (RouteLines.ApplySparklePreset). They are painted on the same two frames from their own pools, redrawn 30 times a second while shown; the leg being travelled starts at the player (within 60 yd, checked every 0.25 s). A draw emits only along the stretches of each run whose segments reach the minimap's face (within radius × √2 of the player, plus a sparkle's jitter) or the map's canvas (Sparkle.Spans, over the run projected once per route, map, canvas size or OpenMap change), at most 4000 places per surface, and sets a texture's colour or shows it only when that changes: its cost follows the sparkles shown, not the route's length. With OpenMap's stitched world map on (OpenMapAPI.IsStitched), the world map places them withOpenMapAPI.CanvasFromWorldPosand lifts the frame aboveOpenMapAPI.ArtTopLevel(). - Chokepoints: a Travel leg ending at one carries
choke = { kind, name };Nav/Layers.luagives its route stop (nav.route) the kind's glyph on a scrim disc in the accent (icon-lift;icon-gatefor gates and building doors;icon-bridge;icon-cavefor caves and tunnels;icon-pass) and the name in its tooltip. Hand-made stops (chain nodes, guide gotos) carry nochokeand keep the plain disc. The route lines' dots leave their gap round every stop alike.
Travel planner and zone crossings#
Polestar.Nav.PlanRoute(p) -> legs | nil, reason -- pure; Nav/Travel.lua
Polestar.Nav.GetRoute() -> legs, ci -- the live route and its current leg
Polestar.Nav.EstimateTravel(from, to, budgeted) -> seconds | nil[, "busy"] -- same planner, cached; budgeted (the API's): no new plan past 25 ms a frame
Polestar.Nav.RegisterTravelData(d, replace) -- TravelData.lua: flight masters, flights, transports, portals, inns
Polestar.Nav.AddTravelPaths(chunk) -- learned walking paths (nodes: c, wx, wy, gateway, uiMap)
Polestar.Nav.RegisterCrossings(d) -- zone crossings (merges; ids seen before are skipped)
Polestar.Nav.travel.SkipLeg() -> bool -- leave this hearth/teleport/flight/ride out for the target; else the next leg
Polestar.Nav.travel.SkipReplans(leg) -> bool -- skipping it replans (the prompts' "Skip this leg"), else "Mark this leg done"
Polestar.Nav.travel.RestoreWalk(c, x, y) -> bool -- the path to (c, x, y) failed or was cleared: the walk's straight estimate again
Polestar.Nav.travel.Remeasured() -> bool -- Walk.Measure landed: plan again, take it only if it goes another way
Polestar.Nav.travel.PricePaths() -> bool -- a later walk's path landed (Walk.LegPath): its legs take its yards and hubs
Polestar.Nav.travel.WalkCostHook(fn) -- fn(walk, cost) -> cost: a planner rule scaling walk costs, never times
Polestar.Nav.travel.HubText(leg) -> text | nil -- "Hostile town: <names>" for a walk with leg.hubs
Polestar.Nav.travel.TakeWarning() -> text | nil -- the current walk's hostile towns, once per walk (the arrow says it)
Polestar.Nav.walk.Measure(c, x, y, water, places) -- one reversed Path.FindMany from the target to the plan's places
Polestar.Nav.walk.Measured(c, x, y, water) -> walkCost | nil -- the planner's p.walkCost for that target: walkCost(c, wx, wy, start) -> cost, yards
Polestar.Nav.walk.LegPath(from, to) -> yards, hubs | nil -- a later walk's searched path: Find's job.yards and job.hubs
Polestar.Nav.travel.Progress() -> seconds, yards -- the current leg's rest (allocation-free, always finite; rides count down)
Polestar.Nav.GetRouteGeometry() -> { legs = { { kind, pts = { { c, wx, wy }, ... }, current, choke }, ... } } | nil
-- leg = { kind = "walk"|"fly"|"boat"|"zeppelin"|"tram"|"portal"|"hearth"|"teleport"|"lift", from, to, label,
-- seconds, rest, yards, radius (arrival yards at `to`), waypoint (true: `to` is a crossing or chain node),
-- handmade (a walk the crossing data lays out), crossing, side (a crossing walked from end a|b),
-- step ("crossing"|"chain"|"goto", or a chokepoint kind), choke = { kind, name } (ends at a chokepoint),
-- stops (fly: every taxi node hopped), transport, side (the ride or portal and the end boarded),
-- lift, side (a lift ride: Transit's lift id, "top"|"bottom" boarded),
-- item (hearth: 6948), spell (teleport), parent, seg, tail (pieces of a split walk),
-- engine ("cost": a walk into the target priced by the engine's measure; "path": by its own path),
-- hubs = { { id, name, areaId }, ... } (a walk whose engine path enters the other faction's towns) }The planner is Dijkstra over the player, the target, known flight masters,
docks, walk-in portals (one way, portals in TravelData), the bound inn,
class teleports, learned path nodes and crossing waypoints. It orders by
a cost (the Hearthstone carries a 120 s margin on top of its time, a
teleport 10 s) and keeps the real seconds of every leg apart, so
seconds/rest (the arrow's ETA) stay honest. Caps: 20000 pops, 40 ms,
1500 path nodes. With Nav/Transit.lua loaded, a ride's wait is the
timetable's (Transit.NextDeparture(kind, id, now, side)) for the moment
the player reaches the dock (plan time plus the seconds walked and flown to
it; timetabled rides are first-in first-out, so Dijkstra stays exact), else
the data's average. The call is wrapped in pcall and its answer checked
(finite, not negative): a broken timetable only falls back to the average
wait, never stops planning. Transit.Teleports(now) lists the castable teleports,
planned like the Hearthstone from the start. While a ride waits, the leg's
caption gains Transit.Caption ("Next boat in 1:20"), refreshed every
second; a crossing at a lift (Transit.LiftNear) the same.
A transport end is its ship's stop; where the ship lies in the water off
its pier, the end carries a board point on the deck (TravelData's board,
below), and the dock vertex is there: the walk to the boat ends on the
deck, with its height, and the boarding (60 yd), leaving (90 yd) and
arrival (120 yd) checks measure from it. The ride, its caption and its
timetable stay the transport's (Nav.GetTravelData() ends keep the stop,
end.board the deck).
NAV_TRANSIT(changed, own) plans again at once for the player's own rides
and teleport changes (own, or changed nil); a shared time from another
player only when a leg from the current one on uses a changed service
(changed[leg.transport.id] or changed[leg.lift]), at most every 20 s
(a held-back one runs on the next Refresh after that). A ghost
(UnitIsGhost) plans walking only. SkipLeg leaves the current hearth,
teleport spell, flight (its departure flight master), ride or portal out of
every plan for this target (another target may use it again) and plans
again; a walk or lift step just moves on. Progress counts a ride, flight,
hearth or teleport down from when it became current (a flight from take-off,
when the player is first on the taxi); Remaining, the arrow's ETA on those
legs, counts down the same way. EstimateTravel
evicts its oldest cached answer when 64 are held. A budgeted call (only
API.Estimate's) starts no new plan once the frame has spent 25 ms planning
estimates (debugprofilestop) and answers nil, "busy" until the next
frame; the plan it does start may still run to its 40 ms cap. Polestar's own
callers (ns.TravelCost, Nearby.Rank, the item sources, the dungeon
pickup order) are never refused, so a ranking compares full plans only; their
own caps and the cache bound them. The arrow's ETA adds the 1.25 detour only to straight distances
(Nav.Sample's sixth value measured is true along a path).
Read the rest of this section6150 words
Walking paths. Nav/Walk.lua lets the arrow follow real paths on the
ground with Shortest Path Forever's walking engine ("In-game walking routes
(Polestar_Nav)" below). The planner above stays synchronous; its walks into
the target are priced by the engine's measure (below), its other walks by
straight lines until their paths are searched. The walk the arrow is
on is searched first: player to the current walk leg's end, or to the target,
with Path.Find on the player's continent (from the player's height,
Compat.GetPlayerUnitPosition; a ghost with waterWalking). Until the answer, and
whenever there is none, the arrow points straight as before. With a path,
Nav.Sample follows it with padding, so a road's little wiggles do not
jerk the arrow: the next point is passed
within 5 yd (the nearest segment among the next 12 decides how far along the
player is), and from it the arrow aims at the furthest path point up to
30 yd along the path (AHEAD) such that every path point on the way lies
within 10 yd (PAD) of the straight line from the player to it, neither
behind the player nor beyond it along that line. A real corner (a point
more than 10 yd off the line) still turns the arrow, gradually from about
30 yd out and fully at the corner; past the next point it aims at the
middle of that point and the one before, so a zigzag of points either side
of the road cancels out. It never aims past a point more than 6 yd
(FLOOR_STEP) above or below the next one (another floor), past the path's
end (the leg's goal: crossings, approach chains and guide gotos are legs of
their own), or, on a piece of a split walk, past the last path point before
its chokepoint (Travel.Aim passes leg.seg). The yards it reports are
still the yards left along the path (to the next point, then along it),
never to the aim point; Travel.Aim returns those yards as a fifth value.
The arrow eases into each new direction (Nav/Arrow.lua's DAMPING).
A new goal (more than 1 yd away from the searched one) cancels the search
in flight and drops the path; more than 25 yd off the path asks again (at
most every 2 s, the old path kept meanwhile); a failed search (nodata,
outside, offmesh, unreachable, error, Path.Available(map) false,
or Find raising) is asked again only after 15 s and 25 yd. Nothing is
asked for in combat (the engine's own queue waits out combat as well).
The walk to the target's own point carries the target's height: the Engine
waypoint's z (a guide goal at an NPC or object, Guide data), which
Nav.GetTargetWorld() returns fourth (a target set with Nav.SetTarget has
none). It goes to Path.Find as to.z, so the path ends on that floor; a
search with it that fails, or whose path's last engine point before the goal
lies more than 30 yd above or below it, is asked once more without it, and
that answer stands for the goal (later searches for it, after straying, go
without it too). A walk to a leg's end with a height of its own (a dock's
board point, a tram, zeppelin or portal end's floor: "End heights" below)
carries that one the same way; walks to other leg ends go
without a height (docs/NAV_QA.md Z1-Z3). A later walk from a board point
(off the boat) starts at the deck's height (from.z; asked once more with
neither height when that fails), and the measure into the target gives each
dock place its deck height (FindMany's per-target z).
The player's floor (#234, Ruling 36). Forever's UnitPosition gives no
height (Compat reads its 0 as none), and Path.Find from a point without one
starts from the cheapest surface within a cell of it: a roof, rock top,
bridge or upper floor above the player (at the Den's camp in the Valley of
Trials, the rock top over the Den, whence Fastest crossed the hill to
Hana'zua). So Walk.lua follows the height of the surface under the player:
Walk.Aim checks at most every 0.25 s and reads the cell's surfaces
(Path.SurfacesAt, into one reused table) only after moving 1 yd. Unknown,
it seeds where the cell's surfaces lie within 10 yd (the router's ZTOL:
the lowest), or, when Compat.IsIndoors() answers and the surfaces differ
in indoor-ness (class indoor or indoor city against the rest), on those that
match it when they lie within 10 yd (off the Undercity's lifts: the city
under Tirisfal's outdoor ground); it never overrides a followed floor with
the indoor state. Known, it follows the surface nearest it, never more than
4 yd above (a ramp or stairs at mount speed, plus a jump); any drop is a
fall; none is unknown. It is unknown again on another continent, after more
than 30 yd from where it was last read (a hearth, teleport, landing, loading
screen), after moving with no check for 2 s, on a taxi or a transport
(Transit.Riding), within 8 yd of a lift (Transit.LiftNear: it changes
level without moving), on a ghost state change, and on a read that raises.
The read never decodes: a cell whose cluster is not decoded reads nil and
queues Path.Prefetch, which decodes it under the router's frame budget as a
search's clusters are (not in combat); meanwhile the floor is kept, checked
but not read, and followed from there once the cluster is in. The arrow's
search starts from the client's height when it gives one, else from the
floor when known, checked within 2 s and read within 5 yd of the player
(from.z); a search with it that finds nothing is asked once more without it,
keeping the goal's height (one that ends off the goal's floor drops the
goal's height, Z2, keeping the start), and that answer stands. The player's
place in Walk.Measure takes the same height. Unknown, every search is as
before; when the floor then becomes known within 5 s of a search sent
without a start height (an arrival: login, hearth, a landing; ambiguous
ground walked off), it is asked once more from the floor (Ruling 36b), once
per such search and never in combat. The router keeps the one cluster the
floor reads decoded, through Idle too: the one the player stands in (a read
of another decoded cluster takes the slot at once; a Path.Prefetch that
lands after the player walked back keeps out of it). Path.ReleaseSurfaces()
cancels a pending prefetch and drops it: the target cleared or no Walk.Aim
for 2 s (Walk:Refresh), never a style change, a level-up or a hand-made
walk, which only clear the walk.
Hand-made walks win: a walk leg from a crossing or chain waypoint, to a
chain node past its mouth, or from inside a chain (leg.handmade), and a
guide goal whose last located goal before it, across steps, is a goto
(the author's way in: a cave's mouth; the goto itself is walked) keep the
straight arrow, so a target underground is never routed over the hill
above it. nav.navRouting (default on, Guide > Arrow & routes) off, the
engine missing, or Polestar_Nav not loaded: the straight arrow everywhere.
GetRouteGeometry (rebuilt on NAV_ROUTE_GEOMETRY, which fires on every
plan, leg change, target change and path arrival) gives the map lines the
legs from the current one on: the current walk as the engine's points once
in, else player (or leg start) to leg end; a later walk as its own searched
path once in (below), else straight; a flight as its hop nodes; a ride or
portal as its two ends; a hearth or teleport as from and to.
Route style. nav.routeStyle (Guide > Arrow & routes > Routes, "Route
style"; "fastest", "balanced" (the default) or "roads") goes as
opts.style with every search Walk.lua makes: the arrow's own walk, the
planner's measure (FindMany) and every later walk. It is sent as saved;
the router (Costs.Style) reads anything else (an older build's value, a
typo, nil) as Balanced. A change (Walk:OnSettings, which notes the style
at the first settings pass) drops every walk priced in the old style: the
arrow's path (Walk.Clear, straight until the new one is in), the
measure in flight and the kept measures, the later walk in flight and the
kept later paths; then Travel.Replan plans at once, its plan measures the
walks into the target again and the arrow and the later walks are searched
again, all in the new style. No line drawn in the old style stays.
Zone danger. Every search Walk.lua makes also goes with opts.danger,
Nav.danger.Table(level, startZone, goalZone) (Nav/Danger.lua): every zone
with a level range (Maps.EachZoneLevels) whose lowest level is at least the
player's + 4 (x3) or more than + 10 (x20, the other faction's towns' strength), Nav.danger.Mult; a zone without a
range (a city, an unknown one) never counts, and nil when none does. The
idea and its 4-level threshold come from the translated names database (Yendi, ding-travel
2bccf01). The zones a walk starts and ends in are left out (Path.ZoneAt at
its two ends: the player and the arrow's goal, a later walk's from and to),
so a quest inside a dangerous zone is still walked to; the planner's measure
(FindMany, one opts for all its places) leaves out the target's zone only,
so a place inside a dangerous zone is priced through it. A table is built
once per level and zone pair (the router keys its caches by its signature).
The level is Nav.danger.Level(): Compat.GetPlayerInfo().level, or
PLAYER_LEVEL_UP's when higher (the client's can lag the event; Danger.lua's
own handler keeps it, registered before Walk.lua's). A level-up drops every
walk priced at the old level and re-plans at once, as a style change does.
The journey planner's straight estimates take the same rule
(Nav.danger.Along(danger, map, x1, y1, x2, y2), p.walkDanger): a straight
walk's cost (never its time) is multiplied by the mean, over the middle of
each 100 yd piece of the line, of danger[Path.ZoneAt(...)] (1 elsewhere),
the zones of its two ends never counting; the table is the plan's level's
without the player's and the target's zones (Travel.Replan and
Nav.EstimateTravel alike; the latter's 5 s cache is keyed by the level
too). A walk half through a far-above zone costs x10.5, half through a
dangerous one x2. The zones read are kept per chunk (16 to a tile) for the
session's Path.ZoneAt, so later plans between the same places ask the
router nothing. With nav.navRouting off or the guiding handed to Shortest
Path Forever (Nav.HandedOff()), the next plan's estimates are today's at
once, even though the loaded engine stays; likewise without Path.ZoneAt (no
maps), its zone field or a dangerous zone. A measured walk (p.walkCost)
already carries the router's pricing.
Walks priced by the engine. A straight line x1.25 cannot tell a walk
round a ridge from one over it, so the planner would land at a flight master
whose straight walk crosses a mountain. On a continent the engine covers
(Path.Available), each plan hands Walk.lua the places a walk to the target
can start from (p.places: the player, known flight masters, docks and
portal ends, the bound inn, teleport landings, crossing ends and chain
mouths), and Walk.Measure runs one Path.FindMany from the target (at the
target's height, Nav.GetTargetWorld's fourth value, so a walk into an
Undercity quarter is measured to its floor; a batch with it that finds
nothing is asked once more without it) to the nearest 16 of them within
3000 yd, reversed (reverse = true), so each cost is that place's walk to
the target (after Shortest Path Forever's goal batch,
Journey/JourneySearch.lua, v1.16.0), with its yards beside it
(job.yards[i]). The next plans take p.walkCost (Walk.Measured: cost,
yards) and order those walks by the cost and time them by the yards, at run
speed, with no detour;
their legs carry engine = "cost" (a target inside an approach chain is
reached by the chain's straight link and keeps its straight price). Rules: a
place the engine finds no walk from (false: Thunder Bluff's flight
master, on a tower it cannot snap from) and every place when none has a walk
(the target snapped off the ground) or the batch failed keep the straight
estimate; a place farther or beyond the nearest 16 keeps it too; the
player's measure holds within 50 yd of where it was taken. Farther, the
player's walk counts as missing from the measure, so the next plan (at most
once per replan) measures it again; meanwhile it is that measure less the
yards moved (its cost scaled alike), never less than the straight distance (x1.0), so a walk never
gets dearer for coming closer. A walk from a crossing end stays hand-made
(drawn straight, never searched); only its price is the engine's. Measures
are fixed per target (the last 4 targets are kept), so plans never
flip-flop on them; a target is measured again only when a plan has a place
its measure lacks (a flight master learned, a new bind, the player come
within reach or walked away). When a measure lands, Travel.Remeasured
plans once more and takes the new plan only if it goes another way (other
flights, rides, portals, hearth, teleports, crossings or chain waypoints);
the same way keeps the route and its progress, and its later walks take the
fresh plan's prices (a walk already priced by its own path keeps that). On a
flight it waits for the landing (which plans again), on a boat for the next
Refresh after the ride. One batch runs at a time; a new target cancels the
old one's; none starts in combat (owed until it ends; the engine's queue
pauses a running one), with nav.navRouting off, or while handed off to
Shortest Path Forever (Walk:Refresh cancels one in flight). The planner
itself never waits: the first plan is straight, the measured one follows a
few frames later.
Every later walk's path. Once the plan has settled (no measure running)
and the arrow's own search for the walk it is on has answered (checked on
every ROUTE_CHANGED and ROUTE_ADVANCED: the current walk's goal must be
the one searched, with a path or a failure; a flight, a ride or a hand-made
walk has none to wait for), each later walk leg that is not hand-made, not
a piece of a split walk and not the walk on from a guide goto (the arrow
goes straight from the mouth) is searched once with Path.Find, one at a
time, the last walk to the target's height (once more without it, as Z2),
kept by its ends and water mode (the last 32; a failure is kept too and
stays straight). When it lands the route lines draw that leg along it and
Travel.PricePaths gives it the path's yards (Find's job.yards), run-speed seconds and hostile towns (leg.hubs)
(engine = "path"), so the journey panel's times follow the ground; the
boats, zeppelins and trams after it are timed again by their timetables from
the new arrival (the current leg counts whole). A fresh plan through the
same walk takes the kept path at once. A search whose leg leaves the route
is cancelled; none starts in combat, with nav.navRouting off, or while
handed off (Walk:Refresh cancels one in flight).
Chokepoint steps. When the path arrives, the walk the arrow is on is
split into legs where the path passes a chokepoint. It splits at the
lifts Transit.Lifts() knows when Polestar_Nav has no chokepoints, plus the crossings, approach chains and guide
gotos the planner already makes legs of, and Polestar_Nav's
PolestarNavChokepoints[map] table (Nav/Chokepoints.lua, generated by nav pack: lifts, gates, bridges, tunnels, passes, cave and building mouths, each
with a radius), read instead of the Transit lifts. A chokepoint counts within its radius of the path, snapped to the
nearest point on the path (the step ends there, so no leg runs out and back:
K5); ones within 20 yd of the player, the walk's end or the step before are
dropped (K7), and ones within 30 yd of a crossing, chain node or a guide
goto end leave the hand-made caption alone (K8). Captions: a passage names
the area on the side the path reaches 25 yd past it ("Cross the bridge to
Lakeshire", "Through the gate into Orgrimmar"); a cave or building its
name ("Walk to the Frostmane Hold cave mouth"); without a name, the kind and
the zone ("Through the gate in Durotar"); never empty or "Unknown" (K1). A
lift is a walk to the landing the path reaches first ("Walk to The Great
Lift, Bottom": the side comes from the path's direction, K4) and a lift
leg to its paired landing ("Take The Great Lift"), timed and captioned by
Transit.NextDeparture/Caption("lift", ...) (30 s without); other steps
carry no wait (K3). The names in the captions are the client's, in its
language: each chokepoint carries the AreaTable ids of its name, zone and
sides (area, zoneArea, read with Names.Area) and a lift its
GameObject (liftObject, the names packAn addon of guides for Polestar, built by the compiler from a folder with a pack.json (the folder is a pack too); Polestar's own is Polestar_Guides (pack id guides).'s object name, else the Transit
lift's area name); the English names are kept for enUS and as the fallback.
The pieces' seconds are whole seconds of the path at run
speed that add up exactly to the walk (K6), and every piece keeps parent
(the whole walk), seg (the path segment its end lies on) and tail (path
yards after it): the arrow keeps following the one path to the whole walk's
end and moves to the next piece within its radius or once past its segment.
The split runs once per path (Travel.SplitWalk, ROUTE_CHANGED) and again
after a fresh plan of the same walk from the path already in, so the steps
stay the same as the player walks (K2). A walk with no stops on its path
stays one leg and takes the path's measure: yards the path from the
player to its end, seconds that at run speed (the planner's pace), its
straight estimate kept in straightYards/straightSeconds; ROUTE_CHANGED
fires as for a split, so the journey's legs, Progress, Remaining and the
panel header agree. When that path's search fails or the path is cleared
(Walk.Clear: no target, nav.navRouting off, a hand-made leg),
Travel.RestoreWalk(c, x, y) puts the straight estimate back. Split pieces
keep their path times.
Zone crossings. Without crossing data a continent walks as it always
did: straight lines between any two points on it (x1.25 detour) plus learned
paths. With full crossing data for a continent (complete = true), a straight
walk stays inside one zone; between zones it goes only through
- a crossing: two waypoints, one in each zone, at a tunnel, pass, bridge, gate
or lift, each with an arrival radius, joined by an explicit walk link, with a
caption per direction (
ab,ba; else "Go to <zone>"), optionally one way; - an open-ground pair: two zones whose shared border is walkable anywhere (optionally one way);
- an approach chain: ordered nodes with radius and caption (a cave, a climb, a
dungeon approach). Only the mouth (and with
open = "both"the last node) links to its zone; the other nodes link only to their chain neighbours. A start or target within max(2 x radius, 30) yd of such a node is inside the chain and attaches only to the nearest node and its neighbours, so routes enter at the mouth and leave the way they came ("Follow the path out", or the node'sbackcaption).
A point's zone is its uiMap when the crossing data knows that zone; a zone
the data lists as wild (no crossing data, e.g. the zones only Forever has)
walks as before; any other map (a continent map, an instance) is placed in
the smallest known zone frame holding the point (Forever's frame where
Forever redrew the map, Stormwind City with its harbour, else Era's). Walk
legs end at every crossing
or chain waypoint and carry its caption and radius; the arrow shows the
current leg's caption and points at its waypoint ("Enter Orgrimmar" at the
gate, then the target's own label).
Re-planning. On target, zone and subzone changes, landing, the
Hearthstone coming off cooldown, binding, the flight map opening, a flight
path learned (UI_INFO_MESSAGE ERR_NEWTAXIPATH: the flight master within
60 yd is marked known), and, with crossing data, on arriving at a crossing or
chain waypoint and on moving more than 50 yd from where a plan at least 30 s
old was made (checked at most once a second). ROUTE_CHANGED (legs) fires on
every plan, ROUTE_ADVANCED (legs, ci) when the current leg moves on.
Data. node tools/polestar.js travel build writes TravelData.lua
(committed: flight masters and flight times, boats, zeppelins, the tram and
inns from the client tables, learned paths, the shipped zone crossings, and
crossings learned from zone changes in recordings,
tools/lib/travel/crossings.js recordedCrossings, registered with
complete = false: they refine a continent that has full crossing data and
change nothing elsewhere). Names the player reads come from the client in
its language: a flight master by its TaxiNodes id (Travel.TaxiName, the
names pack's taxi), a dock, portal end, teleport landing or inn by the
AreaTable area each carries (Travel.PlaceName, Names.Area), a boat's
route from its two ends' areas, the tram and the lifts by their area
(L["%s, east lift"] and its kin), a class teleport by its spell; the
English names stay for enUS, logs and matching. The shipped crossings are the full crossing data
of both continents (complete = true): tools/lib/travel/crossings.json,
committed, written by node tools/polestar.js travel import --<crossings> <ZoneConnections.lua> --source <text> from an Era map addon's Classic Era zone
connections (GPL-3.0; tools/lib/travel/<crossings>.js): its walks between
two zones of one continent, both ways unless an Era map addon marks one way, every
end with a 30 yd arrival radius (an Era map addon gives none); its boats, zeppelins,
tram and portals are TravelData's own transports and are left out. Its
open pairs are not an Era map addon's: borders the navigation grid finds walkable
along their length (nav aids open pairs; Elwynn Forest and Duskwood,
Duskwood and Westfall, whose river the walking engine swims), so a walk
across them is not sent round to a road crossing (#371, #421); a re-import
keeps them and its source says so. travel build --shipped none builds without them (the test fixtures). An optional
local crossings file, data/local/crossings.json (gitignored), adds to them:
the build validates it and writes it into the local data addonA companion addon that carries data, not code, mostly loaded on demand: Polestar_QuestDB, Polestar_ItemDB, Polestar_Codex, Polestar_Travel.
addon/Polestar_Travel (gitignored; ## Dependencies: Polestar, registers
with RegisterCrossings at load, which merges; installed when built, never
packaged). Without the file nothing is written and TravelData.lua never holds
it.
Board points. A boat's stop (its TaxiPathNode stop) is where the ship
lies, 12-14 yd off its pier in open water at most docks. travel build
reads the navigation grid (--grid <dir>, default data/local/nav4;
--grid none or no grid: no board points, one warning) and gives every end
whose point is not on dry walkable ground (a grid column with a surface
that is not swum, not blocked and above the water there) a board point
{ wx, wy, z } (tools/lib/travel/transports.js boardPoint): the
nearest such surface within 30 yd, in a region of at least SNAP_REGION
surfaces and open both ways (path.js's snap rules: not a piling or a pocket
under a floor), at equal distance the larger region (the one the town is
in), then the lower surface; an end with none warns. travel.json carries it
as the end's board, TravelData.lua as the end's tenth field (after its
AreaTable area). Zeppelin and tram ends stand on their platforms and get
none.
End heights. An end on dry ground gets a height instead
(transports.js endHeight; with no grid, none): the surface of its
column nearest the height of what stands there, within 10 yd, by the same
region and open rules. That height is the Deeprun Tram's AreaTrigger in
Stormwind and Ironforge, a portal's AreaTrigger and its the server database
destination, or the spawn of the dock NPC an end is placed at (zeppelin
masters, wharfmasters: data/forever/spawnz.json, within 15 yd of the
end); never a ship's stop (the ship's origin: a zeppelin's lies about 17 yd
above its tower). Without it the walk ended on the cheapest surface of the
cell: the roof above the tram's entrance, 417 yd round by the Keep where
the entrance is 131 yd away (#338), or the foot of Orgrimmar's zeppelin
tower. travel.json carries it as the end's z, TravelData.lua as the
end's tenth field (a number, where a board point is a table); an end with
a source height and no surface near it warns (the Undercity tower's top
under Hin Denburg is not open both ways on the grid).
The crossings file (schema 1, Era map percent):
{ "schema": 1, "frame": "e",
"crossings": [ { "id": "durotar--orgrimmar",
"a": { "map": 1411, "x": 45.5, "y": 12.1, "radius": 15 },
"b": { "map": 1454, "x": 52.5, "y": 84.8, "radius": 15 },
"ab": "Enter Orgrimmar", "ba": "Leave Orgrimmar", "oneWay": false } ],
"open": [ { "a": 1429, "b": 1431, "oneWay": false } ],
"chains": [ { "id": "a-cave", "name": "A Cave", "open": "mouth",
"nodes": [ { "map": 1413, "x": 45.9, "y": 36.2, "radius": 20, "caption": "Enter the cave" },
{ "map": 1413, "x": 46.2, "y": 34.9, "radius": 15, "caption": "Follow the path", "back": "Climb out" } ] } ] }Validation (every error names its entry, e.g. crossings[3] (id).a: radius 500 outside 3-100 yd): no unknown keys; ids lower-case, unique across
crossings and chains; maps with an Era frame; x, y in 0-100 unless the point
says "offMap": true (then -50-150); radius 3-100 yd; a crossing's ends on one
continent, in different zones, at most 1000 yd apart; open pairs distinct,
on one continent, not repeated; chains of 2+ nodes on one continent with hops
of at most 600 yd; captions plain text of at most 160 characters (no |
escape codes). travel route runs the addon planner and includes
Polestar_Travel when it is built (--crossings-lua none leaves it out).
Chains from recordings. travel chain --to <map,x,y> [--name <place>]
drafts an approach chain from the recorded walks that pass within 30 yd of a
target, for a person to check in game before it goes into the crossings
file (--session <id>, repeatable, keeps only those sessions' walks). Each
pass by the target is an approach on the way in and, with --reverse, on the
way out reversed (a way out is not always a way in), followed back --within
yd (300); a shorter one does not count. It leaves the straight line where it has walked 20 yd more than a straight walk
to the target would have, and its path from there to the target, simplified
into nodes (Douglas-Peucker, --tolerance 8 yd), is its chain. Walks from
different sides meet at the real mouth, so the shortest chain is printed,
with every approach's departure point beside it. An approach that keeps to
the straight line is reported with a warning but does not stop the draft:
recordings have no height, so a walk that followed the arrow onto the hill
above a cave looks straight too; only when every approach is straight is
there no chain. Radii are at most 15 yd at
the mouth and 10 inside, and under half the shortest hop; captions are
left unset, so the addon's translated "Follow the path" applies (the mouth's
is "Go to <place>" with --name). A single
walk from the side away from the mouth starts the chain where it turned off
the straight line, around the mountain: a walk from the open side, or the
reviewer, moves the mouth. The walks are tools/lib/travel/paths.js
recordedWalks, the ones learned paths are built from; the chain is
crossings.js recordedChain.
Aids from the navigation grid. node tools/polestar.js nav aids
(tools/lib/nav/aids.js; the grid is described under "Navigation grid")
generates a file in this schema from the grid (nav build) with
no per-place rule: a surface's zone is its chunk's area id walked up
ParentAreaID to a zone map with an Era frame (Ironforge and Undercity, whose
chunks belong to the zones around them, stay wild); a zone's open ground
is its largest component over gentle links (up to 35°, jumps, swims, lift
rides) inside the zone; a zone pair is open when at least 60% of its land
border joins the two open grounds, no blocked stretch passes 400 yd and most
probes across route easily (no steep ground or climbs, at most x1.6 the
straight line's time); otherwise every walkable passage (border links and
falls between the open grounds, joined within 40 yd, cut at 200 yd) that an
easy route crosses is a crossing (ends 25-60 yd inside each zone along that
route, radius half the passage's width, one way when only falls cross it;
captions for tunnels, model bridges over liquid or air, and lifts). Every
place the guides, quest givers and enders, flight masters, inns, trainers and
dungeon entrances use is tested for a pocket: when most straight lines from
up to 12 open-ground points around it are not walkable and 75% of the routes
from them share their last 40 yd or more, through a cave (indoor under
terrain), a building, a lift, a climb or a narrow way, that stretch becomes
an approach chain (targets on one chain's way join it). It writes
aids.json in the grid's folder (data/local/nav/aids.json by default,
--out another file; source and build, the grid's client build,
on top) and with --report aids-report.{json,md}: every consecutive pair
of located goals in the guides planned by tools/lib/nav/arrow.js (many
PlanRoute calls in one Lua process) without and with the aids, and how many
walk legs cross ground the grid cannot walk (nav.los), plus a comparison
with the local crossings file (never changed). --lua <file> writes the
aids as a Crossings.lua; node tools/polestar.js travel build --offline --crossings data/local/nav/aids.json --travel-addon <test folder> --json <test folder>/travel.json --lua <test folder>/TravelData.lua builds a test
copy of the data addon from them without touching addon/Polestar_Travel or
the committed travel files. Shipping them is the owner's call.
Timetables, portals and teleports (Nav/Transit.lua). Ported from
Shortest Path Forever by Cillian Berragan (GPL-3.0-or-later, used with
permission: his Transport/Model.lua, Timetable.lua, Observer.lua,
Sync.lua, Teleports.lua and tools/gen_routes.py, gen_transit.py).
Polestar.Transit.Now() -> server seconds with a fraction
Polestar.Transit.NextDeparture(kind, id, now, side) -> wait, ride, timed | nil
-- id: TravelData transport id (or the entry), or a lift id; side "a"|"b", "top"|"bottom", nil = soonest;
-- now: when you reach the dock. wait until it leaves with you aboard (its `board` seconds early
-- enough), ride until you are off; untimed: the expected values, timed = false
Polestar.Transit.NextArrival(kind, id, now, side) -> arriveIn (0 = there), departIn | nil while untimed
Polestar.Transit.Caption(kind, id, now, side) -> "Next boat in 1:20" | "Boat docked, leaves in 0:40"
| untimed "Boat: average wait 2:55"
Polestar.Transit.Riding(now) -> { kind, route, dock, arriveIn } | nil -- the ride the observer identified
Polestar.Transit.Lifts() -> { { id, name, c, wx, wy, top, bottom }, ... }; LiftNear(c, wx, wy [, yards]) -> id, name
Polestar.Transit.Teleports(now) -> { { spell, name, cast, ready, to = point | nil, bind }, ... }
-- NAV_TRANSIT fires when a sighting changes a timing or the usable teleports change.Every transport runs a fixed loop, but where in it the loop is depends on
the server's uptime, which no API tells. The data gives each loop (routes)
its period, stops (arrive and depart, ms into the loop) and track frames;
each services entry (a TravelData transport, the tram, a lift) the loops
serving its two ends, its boarding margin (boats and zeppelins 10 s, the
tram 5 s, lifts 2 s), the tram's loading screen and walk in and out
(pre, post: 15 s each) and its expected wait and ride while untimed.
A sighting dates a loop (epoch: server ms at phase 0, kept modulo the
period; phase = (now - epoch) % period). The observer samples the player's
UnitPosition (through Compat) once a second; moving at least the loop's
fit speed (12 yd/s for boats, 6-8 for lifts) within 40 yd of a moving leg
(10 yd for lifts) gives the phases there; the phase offset most samples of
one ride agree on (within 8 s, 2 s for lifts), spanning at least the fit
span (30 s, lifts 2-4 s; a bystander's offsets drift a second per second),
dates the loop, once the ride clearly fits one loop (1.25x the runner-up:
loops sharing a lane both fit). It fits every 10 samples and when 30 s pass
without one or the player leaves the world; a new sighting prints "Timed the
<route> from your ride" unless one of yours is under 10 minutes old.
Sightings are kept per realm in PolestarDB.transit[realm][route] = { epoch, seen, source } and dropped after 6 hours (a restart resets the loops).
Sharing is opt-in: with nav.shareTransit (off by default; Options, Arrow &
routes, "Share boat times") a new sighting of yours goes to the guild and
group as an addon message (PolestarT1: S<route>:<seen s>:<phase ms at seen>;...; never to a raid in a battleground or arena). A guild query (Q,
sent 15 s after login) is answered after a random 2-12 s, at most every 30 s
per channel, with your own fresh sightings no other reply on that channel
has carried as new meanwhile. Incoming ones count only from guild, party
and raid (never whispers, instance chat or a battleground raid), from a
sender on this realm (a name with another realm is dropped), when valid (a
known loop, phase inside it, at most 5 s in the future, then dated now,
under 6 hours old) and newer than the one held. Your own ride always
replaces another player's sighting; another player's never replaces your
own under an hour old. Your own rides fire NAV_TRANSIT(changed, true) at
once; shared sightings are gathered and fire NAV_TRANSIT(changed) at most
every 20 s, changed naming the transports and lifts retimed, so the
planner replans only when its legs use one. A departure counts only if it leaves at least board seconds
after you reach the dock; a docked visit leaving sooner gives way to the
next. The ride is to the next arrival at the other end. A service whose
loops are untimed costs its expected wait: over a uniform phase (and
independent phases for its several loops, Thunder Bluff's two cars and the
tram's two trains), board + the integral of the chance every loop is still
to leave, about half a loop for a single departure; its ride is the
gap-weighted mean. The planner (Nav/Travel.lua) costs a ride with
NextDeparture at the time it reaches the dock and captions the current
unboarded ride with Caption. Teleports lists the class teleports
(nav.teleports, default on) the character knows (Compat.IsSpellKnown)
with their reagents in the bags (a Rune of Teleportation for a mage's city),
ready the seconds of cooldown left (Compat.GetSpellCooldown; the global
cooldown does not count); Astral Recall is a bind teleport to the bound
inn. Walk-in portals (TravelData portals: Darnassus <-> Rut'theran
Village, 5 s) are one-way links a -> b for the planner.
travel build makes this data (tools/lib/travel/timetable.js, after
Shortest Path Forever's generators). Boats and zeppelins: the the server project
<server-db> transport model (TransportMgr::GenerateWaypoints:
Catmull-Rom spline lengths sampled three times a segment, 30 yd/s with
1 yd/s² acceleration and braking, each stop's TaxiPathNode Delay docked)
over the loop's client TaxiPathNode points, stretched onto the period the server project
the server database sniffed from the original servers (TaxiPaths 241, 285, 292, 293,
295, 301, 302, 303; the Forever-added loops by the mean of those
corrections, about 0.05%). A sniffed loop the model misses by more than
0.5%, or one too short to model, keeps the distance estimate with a
warning; otherwise its expected wait and ride replace the transport's
ab/ba/waitA/waitB. Lifts and the tram: the Forever client's
TransportAnimation (offset from the spawn by time index, Y reversed as
the server project ElevatorTransport::Update reads it), placed at the the server database
gameobject spawn of each car (seven lifts, by entry and spawn guid; cars
sharing a period share one loop) and of the trams' two lead trains, whose
stations split at y = 1200 in the tram instance. Portals: the Era client's
AreaTrigger 527 and 542 to their the server database areatrigger_teleport
destinations. Teleports: SpellEffect teleport effects (5 or 252) to a
spell_target_position (implicit target 17) or the bind point (9), learned
by a class (SkillLineAbility.ClassMask), named by SpellName, cast time
from SpellMisc + SpellCastTimes, reagents from SpellReagents; the
Hearthstone stays the planner's own leg, and a destination on no mapped
zone (Teleport: Dalaran) is skipped. The the server database dump (pinned commit
22b5146, ~13 MB) is fetched once to data/sources/<server-db>/<rev>/, and only
the rows used are kept beside it in transit.json; the client tables are
cached with the others. Without those sources the boats and zeppelins are
still timetabled and the rest is skipped with a warning.
In-game walking routes (Polestar_Nav)#
The arrow's walks follow real paths thanks to Shortest Path Forever by
Cillian Berragan (https://github.com/cjber/shortest-path-forever (opens in a new tab),
GPL-3.0-or-later, used with his permission). Polestar's walking router
started from Shortest Path Forever v1.16.0 by Cillian Berragan, with thanks;
Polestar maintains it since 2026-10-16. addon/Polestar_Nav holds that
router, running on Polestar's own walking maps of Eastern Kingdoms,
Kalimdor and Zephras Isle (packed from our navigation grid in maps format 2,
grown from his map format, nav pack); Polestar's travel planner (above) keeps choosing
flights, rides and the Hearthstone, and the walking is the router's: the
arrow's walk, every later walk's path, and the measure of the walks into the
target the planner prices (Path.Find and one reversed Path.FindMany per
target, "Walks priced by the engine" above). On a map of format 2 (a ground
class byte per node: road, gentle or steep slope, indoor, indoor city, and the
Alliance and Horde hubPolestar's main window: a rail of sections (Guides, Lookup, Dungeons, Rares) with Options at its foot (UI/Picker.lua). bits) a grid step costs its length times
Costs.Mult(class, profile, style) (Routing/Costs.lua), times the swim
factor in water. The route style (Costs.Style: "fastest", "balanced"
or "roads"; nil or any other value is Balanced) picks the multipliers by
base class, and a hub hostile to the profile multiplies its ground x20 in
every style:
| style | normal | road | gentle | steep | indoor | indoor city | smallest |
|---|---|---|---|---|---|---|---|
| Fastest | 1.0 | 1.0 | 1.0 | 1.2 | 2.0 | 1.0 | 1.0 |
| Balanced (default) | 1.0 | 0.8 | 1.3 | 2.5 | 2.0 | 1.0 | 0.8 |
| Roads | 1.0 | 0.5 | 1.6 | 4.0 | 2.0 | 1.0 | 0.5 |
Read the rest of this section2751 words
Fastest walks the shortest way (steep ground at 1.2 breaks near ties);
Balanced takes a road up to 1.25 times the straight way; Roads up to 2
times, and climbs less. Each graph edge carries one cost per profile
(costAlliance, costHorde, costNone), baked by the packer at Balanced's
numbers (Costs.MULT, Costs.HUB, mirrored in tools/lib/nav/costs.js),
and its yards (the record layout is at PathGrid.lua's adjacent): the
walking maps are the same for every style. The style prices every grid step
(the searches inside a cluster, the connections of the ends to the graph,
the refinement of each crossed cluster) and the straightening; the graph
edges keep Balanced's costs in every style, so which tiles a very long walk
crosses follows Balanced, and the style shapes the walk inside them. The A*
heuristics scale by the smallest multiplier they may meet: a search on the
grid alone by the style's, one that crosses graph edges (and
Path.LowerBound(map, from, to, style)) by the smaller of the style's and
Costs.MIN (0.8), so they stay admissible; Balanced searches exactly as
before styles. The drawn line straightens the found path
(PathGrid.lua's smooth) only over ground no costlier for the profile in
the style than the path stretch it replaces, and into a hostile hub only
where that stretch already entered one, so it never cuts a road's bend or a
hostile hub's rim. A map of format 1 (0.2.4's, no class field) is class 0
everywhere, has one edge cost for every profile and keeps the 1.0
heuristic, so it routes and straightens exactly as before classes, for any
profile and style.
Zone danger. Each map tile of the walking maps carries the zone of its
16 x 16 chunks (the metadata's zone field, nav pack: the grid's chunk
area up its AreaTable parents to a zone of zones.json, as its uiMapID; the
layout in pack.js's HEADER). A search's opts.danger, a table
{ [uiMapID] = multiplier } its caller builds, multiplies a grid step into a
node by its chunk's zone's multiplier, on top of its class, profile and
style; a graph edge costs its baked cost times the larger of its two ends'
multipliers; straightening compares the same products, so the drawn line
never cuts back into a zone the path went round. No zone is closed: when the
only way crosses one, the walk takes it at that price. Multipliers are at
least 1, so the heuristics and Path.LowerBound stay admissible; entries of
1 or less are dropped. The searches' cache tags carry the table's signature
(uiMap=mult by uiMapID), so two tables never share a cached connection or
path, and Path.ReuseMany reuses a batch only for the same signature.
Without danger (nil, {}, or on a map without the field) every search is
byte-identical to before (tested against the router before it, from git).
Path.ZoneAt(map, x, y) reads a point's zone from the same field (nil
outside it), without decoding any cluster. The field is 29 KB of Nav0.lua
(675 tiles), 40 KB of Nav1.lua (928) and 3 KB of Nav2991.lua (72); the
packer needs no map frame for it, so Forever's own zones (Riverglades, Mount
Hyjal, Shen'dralas, Zephras Isle) are there too; loaded, about 0.1 MB.
Polestar_Nav.toc LoadOnDemand, Dependencies: Polestar
Nav\Nav.xml the walking maps -> PolestarPathData[map], then the chokepoints
Nav0*.lua, Nav1*.lua, Nav2991*.lua ours: `nav pack` from the grid (128 cells a tile, 4.17 yd), listed in PACK.json
Chokepoints.lua ours: PolestarNavChokepoints[map] for the walk's named steps
Routing\Costs.lua ours: Polestar_NavCosts, the ground classes' multipliers and the faction profiles
Routing\PathGrid.lua ours from his: decoded clusters and ground classes, memory ceilings, frame slicing, string-pulling
Routing\Path.lua ours from his: HPA* between clusters, the grid inside them priced by class, FindMany's Dijkstra
Routing\PathJobs.lua ours from his: the coroutine queue sharing Path.budget (2 ms) a frame
API.lua Polestar_NavEngine = the Path table, plus Available(map); the memory ceilingsMemory. The packed maps (format 2, all three) are 33.9 MB of files; loaded,
the Lua heap holds about 34 MB (0.2.4's format 1 maps 28 MB; his own three maps were about 12 MB). A search decodes the clusters (ADT tiles) it
crosses: by the engine's own accounting (PathGrid.lua gridSize, an upper
bound) about 3.3 MB a cluster on average: 15.8 MB for Kharanos to
Thelsamar (6 clusters), 28.5 MB for Razor Hill to the Valley of Strength
through Orgrimmar (4), 46.4 MB and 4.9 MB of graph for a 7,700 yd walk from
the Crossroads to Tanaris (17 clusters, none evicted); the decoded graph of a
whole continent is 4.2 MB (Eastern Kingdoms) or 4.9 MB (Kalimdor). His
ceilings, 24 MB of grids and 4 MB of graph, were sized for his 8 yd cells, so
API.lua sets Path.cacheKB 48 MB and Path.graphKB 16 MB while a search
runs (maps format 2's longest corpus walk decodes 40.9 MB of grids and 15.7 MB
of graph) and Path.graphRestKB 6 MB at rest. With no search queued (Idle)
the decoded grids go and the decoded graph is trimmed to 6 MB: measured
standalone (Task 15, LuaJIT heap at the router's callbacks), 33.8 MB with the
maps loaded and no search, 38.3 MB after the six longest corpus walks and
Idle (0.2.4: 27.8 MB and 33.4 MB), and a peak of 79.2 MB seen during those
walks. Since the walking floor (#234) the cluster Path.SurfacesAt
reads (the player's) stays decoded at rest while the arrow runs: one cluster,
3.3 MB on average by the engine's accounting (3.1 MB allocated decoding the
Den's camp's under LuaJIT); none without a target or 2 s after the arrow's
last update.
A map is used whole. API.lua drops a map whose packed tables hold
fewer entries than its counts (a part file missing from the folder, or one
that failed to load), and every map when the client lacks
C_EncodingUtil.DecodeBase64 or DecompressString: the engine then has no
data for it and the arrow points straight, where it would otherwise raise a
Lua error the first time a search reads a missing cluster's graph
(tests/nav-load.test.js). A grid block that fails to decode is the
engine's own case: that cluster counts as empty. The bytes of the shipped
blocks are not checked at load; nav check pins them by sha256.
The handoff. Polestar loads the addon with Data.Pack("Polestar_Nav")
at login or later, never at its own ADDON_LOADED (decoding nothing, it
still builds about 34 MB of packed strings) and never in combat; with
nav.navRouting off it is not loaded. API.lua sets the global
Polestar_NavEngine to the router's Path table; Nav/Walk.lua keeps it as
Nav.Path. The calls are Shortest Path Forever's, with the fifth argument
widened:
Path.Find(map, from, to, callback, opts) -> job -- callback(points, cost, job) or (nil, reason, job); job.yards, job.hubs
Path.FindCost(map, from, to, callback, opts) -> job -- callback(cost, reason, job), no drawing points; job.yards
Path.FindMany(map, from, targets, callback, opts, reverse, progress) -> job -- costs[i] or false; job.yards[i]
Path.ReuseMany(job, point, profile, style, danger) -> bool -- may a batch from job's source serve one from point for profile in style with danger
Path.ZoneAt(map, x, y) -> uiMapID | nil -- a point's zone (the maps' zone field)
Path.SurfacesAt(map, x, y, out) -> n | nil -- the cell's walkable surfaces: out[i] height, out[-i] marks (1 water, 2 indoor); nil: its cluster not decoded (a Prefetch is queued)
Path.Prefetch(map, x, y) -> job | nil -- decode that cluster under the frame budget for SurfacesAt (one job at a time)
Path.ReleaseSurfaces() -- cancel a pending Prefetch; the cluster SurfacesAt reads may go at rest
Path.Cancel(job) ; Path.Busy() -> bool ; Path.Available(map) -> bool
-- opts: a boolean is waterWalking (as every caller passed it before profiles), or
-- { waterWalking = boolean, profile = "Alliance" | "Horde" | nil (Costs.Profile(UnitFactionGroup)),
-- style = "fastest" | "balanced" | "roads" | nil (Costs.Style: anything else is Balanced),
-- danger = { [uiMapID] = multiplier >= 1 } | nil (Zone danger, above) }map is the instance map ID (UnitPosition's fourth value: 0 Eastern
Kingdoms, 1 Kalimdor, 2991 Zephras Isle); points are { x, y, z } in
UnitPosition's frame (x north, y west, z optional: it picks the floor).
Results are { map, x, y, z } from start to goal, points.wet the yards over
water; cost is running yards with a swum yard counting the map's swim
(3) unless waterWalking, and on a map with classes each yard times its
class's multiplier for profile in style (graph edges at Balanced's). Beside the cost every result carries
yards: Find's job.yards is the drawn line's flat length; FindCost's
and FindMany's are a second label along the cost-best path (grid step
lengths and the edges' yards), on a format 1 map the cost itself (its edges
carry no yards). Find's job.hubs = { { id, name, areaId }, ... } lists the hubs
of the map's hubs metadata ({ id, side, name, areaId, disks = { { x, y, r }, ... } }) hostile to profile that the drawn line enters, each once, in path
order ({} without a profile or hubs). Reasons: nodata, outside, offmesh,
unreachable, error. Searches run as coroutines in C_Timer.After(0)
slices of Path.budget ms, wait out combat, and drop decoded clusters once
the queue is empty, except the one Path.SurfacesAt reads (the player's
floor, until Path.ReleaseSurfaces). Path.cacheKB and Path.graphKB cap
them meanwhile, and Idle trims the decoded graph to Path.graphRestKB, all
set in API.lua.
The maps are base64 zlib blocks decoded per cluster with C_EncodingUtil.
nav check holds the maps to PACK.json (grid build, cells, date, sizes,
the sha256 of every file), and Nav.xml to it.
Polestar's side: the faction, cost and yards, hostile towns
(Nav/Walk.lua, Nav/Travel.lua). Every search goes with opts
{ waterWalking, profile, style, danger }, profile the player's faction from
Compat.GetPlayerFaction() (none when neutral or unread: the router then
prices no hub hostile and still routes), style the Route style option
(nav.routeStyle, above), danger the zones far above the player (Zone
danger, above), so roads are preferred (x0.8 in Balanced), the other
faction's towns avoided (x20) and dangerous zones x3 or x20. On format 2 maps a walk's cost is no
longer its yards, so the two are kept apart: the cost chooses, yards are
what is shown and timed.
- The planner's
p.walkCost(c, wx, wy, start)(Walk.Measured) returns the measured walk's cost and its yards (FindMany'sjob.yards[i]): the Dijkstra orders by the cost (at run speed), the leg'ssecondsandyardscome from the yards. Those are the no-profile path's yards per graph edge, so a measured walk that goes round a hostile town inside one cluster reads a little short until its own path is searched (one yards value per edge, for every profile); the reversed batch reads outbound edge costs, off by at most one entered step at each end. - The arrow's walk is measured along its path's points (the drawn line,
whose length is
Find'sjob.yards); every later walk takesFind'sjob.yards(Walk.LegPath,Travel.PricePaths). An enginecostis never shown as a distance. Travel.WalkCostHook(fn(walk, cost) -> cost)registers a planner rule (zone danger is built in,p.walkDanger, above; a hook sees its cost): every walk the planner weighs is handed to each hook withwalk = { from = { c, wx, wy }, to = { c, wx, wy }, seconds }(one reused table) and its cost in the planner's seconds; a number >= 0 back replaces the cost, the time stays.- Hostile towns:
Find'sjob.hubsgoes on the walk's leg asleg.hubs(Travel.SplitWalkfor the arrow's walk: the unsplit leg, or on every piece of a split one;Travel.PricePathsfor later walks; a fresh search with the same towns, by id, changes nothing). The journey panel shows "Hostile town: <name>" (names joined by commas, each name once: two towns can share one) under the step in the warning colour, and the arrow says it once under its label for 8 s when the walk starts (Travel.TakeWarning: once per walk end and towns, until the target changes): under the step's instruction on a route, under the target's label when the walk is the whole route (no journey panel then). A walk whose target is inside a hostile town warns too: the router enters it because it must, and the player is told.Travel.HubTextnames each town in the client's language by its area:Compat.GetAreaName(areaId)(C_Map.GetAreaInfo; the hub record's AreaTable id), else the packed English name, as goto labels andCompat.MapNamedo; the "each name once" rule works on the shown name.
Our own maps, always. The engine reads only PolestarPathData, never an
installed Shortest Path Forever's ShortestPathForeverPathData, even when it
holds the same bake: his engine evicts and resets the decoded entries of its
tables (PathGrid.lua trimming, eviction, Idle, Reset) while ours may be
in the middle of a search that already counts a cluster as decoded, which
would end in a Lua error (docs/NAV_QA.md H1). With both
addons installed and not handing off, each holds its own maps in memory
(handing off, Polestar_Nav is not loaded: "Hand-off to Shortest
Path Forever"); tests/nav-maps.test.js
trims a stand-in for his tables after every frame of one of our searches and
checks the route is unchanged.
Updating. The router (Routing/) is Polestar's code: it is edited here,
tested by tests/nav-router.test.js (synthetic maps with and without the
class field; format 1 against the router before classes, from git) and the
real-map tests, and no tool copies it from upstream any more; its three
files that began as his keep his copyright and the GPL notice under a header
line saying where it started. The maps are Polestar's too (Zephras Isle's
since 2026-10-16, no longer copied from his repository): nav pack
writes all three from the grid, and nav check and
tests/nav-maps.test.js hold them to PACK.json; the test also loads the
addon with the real maps (tests/lua/stub_encoding.lua decodes them in
plain Lua), walks Goldshire to the Fargodeep Mine mouth and holds the engine
to NAV_QA C2 (a cancelled search never calls back), C6 (Stormwind's flight
master to the Mage Quarter never doubles back) and C17 (searches sliced over
frames). Never edit a map by hand: change the grid, the layers or the packer
and re-pack.
Measuring the maps. node tools/polestar.js nav eval
(tools/lib/nav/eval/) runs the engine outside the client: harness.lua
(LuaJIT, else Lua 5.1) loads a folder's maps and router in the client's
order (tests/lua/stub_encoding.lua decodes them), drives Path.Find and
Path.FindMany with the frame pump in hand, and answers one JSON line per
query (cost, the router's yards and hubs when it gives them, points, the
drawn line's yards per zone by Path.ZoneAt, cold and warm milliseconds, Lua
memory; an error reported through the client's error handler during a query
fails that query, and the next starts clean). A route with a level is
priced with zone danger as the addon prices it: the harness builds the
danger table from each zone's lowest level (tools/lib/maps/zonelevels.js),
the start's and the goal's zones (Path.ZoneAt) exempt.
run.js resolves the route corpus
(routes.json: 0.2.4's measured routes, the #98 and #195 reports,
hostile-hub, road, building and slope routes; zone-map ends through
map-frames.json, live frame or Era) and, with --sweep, one FindMany per
zone with located guide goals and
per side, from the zone's flight master; metrics.js measures each drawn
line (the share our grid at data/local/nav3 cannot walk straight, as
0.2.4's maps were measured; the steepest climb; off-road share and yards in
hostile hubs from data/local/nav-layers once built; indoor yards, and
whether an end lies indoors, from the FLAG.INDOOR surfaces of the grid at
data/local/nav4, the capitals apart as the packer's indoor city class);
compare.js judges a candidate folder against --baseline (a folder, or
an earlier --out report, its indoor yards measured again) on the acceptance
checks A1-A8. A5 holds the building routes (Stormwind's streets, a line
through Goldshire's inn) and every route found with both ends outdoors to no
indoor yards; like A4's climbs, each route with some is listed beside the
baseline's yards, and the baseline's own, for a person to judge the only-way
ones (a tunnel 0.2.4 walks too).
License. The folder is GPL-3.0-or-later with his copyright (LICENSE,
CREDITS.md). It is code and data of a GPL program, not 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.:
Polestar's guide-pack exception does not cover it, and nothing from it goes
into a pack.
Quality. Every problem reported against Shortest Path Forever that touches what Polestar uses from it (the router, the maps, route lines, timetables, map clicks, TomTom, the travel prompts) is listed in docs/NAV_QA.md (opens in a new tab) with the check Polestar holds itself to; test names cite its ids (rows 1-16, C1-C17, H1-H6, K1-K8, Z1-Z3).