Nav

Contributor66 min readUpdated

In short

The Nav API, map canvas and pins, route lines, the travel planner and the in-game walking routes.

On this page
  1. Nav API — journeys for players and other addons
  2. Hand-off to Shortest Path Forever
  3. MapCanvas — drawing on Blizzard's map frames
  4. Pins — map and minimap icons for every module
  5. Route lines (Maps/RouteStrokes.lua, Maps/RouteLines.lua)
  6. Travel planner and zone crossings
  7. In-game walking routes (Polestar_Nav)
lua
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 it

Settings 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 goal 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 guide 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 step'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/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.

lua
-- 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.WorldPinOf tells 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 global TomTom answers AddWaypoint, AddMFWaypoint, AddZWaypoint (0-100), AddWaypointToCurrentZone, RemoveWaypoint, ClearWaypoint, ClearAllWaypoints, SetCrazyArrow, IsValidWaypoint, WaypointExists, GetDistanceToWaypoint, GetKey and SetClosestWaypoint through API.Navigate("TomTom", ...), one waypoint at a time; corpse waypoints (the death helper's job) and from = "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.lua never 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 sets TomTom and /way is not claimed.
  • Following TomTom (nav.followTomTom, off; real TomTom only): hooks on TomTom's table: SetCrazyArrow(uid) points Polestar's arrow at that waypoint (not corpse, not from = "Polestar"), RemoveWaypoint and ClearWaypoint of it let it go ("Remove all waypoints" clears through ClearWaypoint alone). Waypoints TomTom restores at login are followed one frame after PLAYER_ENTERING_WORLD, the one its arrow was left on, unless something is picked already. A followed waypoint is not mirrored back.
  • /way (with nav.answerTomTom): claimed at login (SLASH_POLESTARWAY1) only when TomTom is not loaded and no SLASH_* 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 reset clears.
  • Nearby services (/polestar near <kind> [profession]; nav.nearbyMenu adds a "Nearby services" submenu to MENU_WORLD_MAP_TRACKING and MENU_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 of WEAPON_MASTERS (the weapon skills each teaches, noted by hand) of the player's side. Places are the Codex'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 in QoL.data.reagentVendors (qol build: a vendor whose subtitle names reagents); the class trainer is Engine.NearestClassTrainer(). The nearest spawn of each NPC by straight line (the player's continent first), the six nearest of those estimated by Journey.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 is Nearby.MenuItems(): the services, a trainer per profession the character has, and Weapon master, a page of Nearby.Weapons() (a known weapon, by Compat.IsPlayerSpell on its skill's spell, and polearms before level 20 are listed and off); off in combat.
  • The trip back to the guide.Go starts a trip (Nearby.Trip()) and fires NEARBY_TRIP(trip). Nav fires NAV_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, and NEARBY_TRIP(nil) follows each end. On the way, the guide window 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.spfHandoff on; 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 no CurrentStop("Polestar"): one the player started there, or another addon's). It looks on Subscribe's news (else on Nav's once-a-second tick), at PLAYER_ENTERING_WORLD and when the option is turned off; never in combat (the news waits for PLAYER_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 first PLAYER_ENTERING_WORLD (in combat, when combat ends; until then Polestar steps aside as if yielding, since that journey may still guide). Subscribe is called at PLAYER_ENTERING_WORLD and 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
lua
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 it

What steps aside

While 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

One stop through 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

On each target change and at the first 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

A stop refused out of combat (its Journeys off, or a point it cannot place) and a target with no live point: Polestar guides that target itself (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

A stop it took that it then guides nothing to (its 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

Turning 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#

lua
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 | nil

The 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#

lua
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()).

lua
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.lua is 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-fill on 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.lua builds the draw paths once per NAV_ROUTE_GEOMETRY and paints: one frame of Polestar's on the world map's canvas (MapCanvas, Nav), the canvas's size, at PIN_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': walk arrow, flight success, boat npc, zeppelin place, tram and lift object; outlines scrim at 50%.
  • nav.routeLines (default on) with nav.worldPins / nav.minimapPins per 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 is W.Toggle's opts.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 a Media/Nav/sparkle star over Media/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) and nav.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 with OpenMapAPI.CanvasFromWorldPos and lifts the frame above OpenMapAPI.ArtTopLevel().
  • Chokepoints: a Travel leg ending at one carries choke = { kind, name }; Nav/Layers.lua gives its route stop (nav.route) the kind's glyph on a scrim disc in the accent (icon-lift; icon-gate for gates and building doors; icon-bridge; icon-cave for caves and tunnels; icon-pass) and the name in its tooltip. Hand-made stops (chain nodes, guide gotos) carry no choke and keep the plain disc. The route lines' dots leave their gap round every stop alike.

Travel planner and zone crossings#

lua
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 pack'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's back caption).

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 addon 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):

json
{ "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).

lua
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 hub 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:

stylenormalroadgentlesteepindoorindoor citysmallest
Fastest1.01.01.01.22.01.01.0
Balanced (default)1.00.81.32.52.01.00.8
Roads1.00.51.64.02.01.00.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.

text
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 ceilings

Memory. 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:

lua
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's job.yards[i]): the Dijkstra orders by the cost (at run speed), the leg's seconds and yards come 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's job.yards); every later walk takes Find's job.yards (Walk.LegPath, Travel.PricePaths). An engine cost is 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 with walk = { 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's job.hubs goes on the walk's leg as leg.hubs (Travel.SplitWalk for the arrow's walk: the unsplit leg, or on every piece of a split one; Travel.PricePaths for 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.HubText names 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 and Compat.MapName do; 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 data: 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).

Was this page helpful?

Start with, or act

  • PlayersInstall and first run
  • PlayersCommunity guides
  • PlayersFAQ
  • Guide authorsWriting a community guide
  • Guide authorsGuide format cheatsheet
  • ContributorsContributing
  • ActionCopy the AddOns folder (Windows)C:\Program Files (x86)\World of Warcraft\_classic_beta_\Interface\AddOns
  • ActionCopy the AddOns folder (macOS)/Applications/World of Warcraft/_classic_beta_/Interface/AddOns
  • ActionFind an optionEvery setting with its default; type its name here to jump to it.
  • ActionEvery slash command