Recording

Contributor13 min readUpdated

In short

What the Recorder collects and what it never does, the saved data, the upload API and how recordings are trusted.

On this page
  1. What is never collected
  2. SavedVariables (PolestarRecorderDB, per account)
  3. Chunks
  4. Ack file (written by Polestar Client)
  5. Field encoding
  6. Record kinds
  7. Upload API (https://data.polestar.guide/v1)
  8. Trust

This is the contract between the four parts of Polestar's recording program, and the public statement of what it collects. Everything here is open source in this repository.

PartWhereJob
Recorder addonaddon/Polestar_Recorder/Writes compact records into PolestarRecorderDB while the player plays (opt-in).
Recording libraryrust/crates/polestar-recording/Reads the SavedVariables file, decodes and validates records, finds WoW installs, scrubs text, writes the ack file, summarizes. Shared by the three below.
Polestar Clientapps/client/ (Tauri)Desktop app for Windows, macOS and Linux: finds the game, installs the Recorder, uploads new chunks, shows exactly what was sent.
Ingest serverservices/ingest/Receives uploads, validates them with the library, stores them, serves stats, deletion and the maintainer export.
forgeforge record import, forge record pullTurns local files or the server export into data/recordings/<session>.json for the data build. import marks this PC's own installs' contributor local. A file someone sent in needs import --vouch <name> (contributor sent-<name>, a name the maintainer chooses; trusted like the maintainer's own) or import --anonymize (contributor anon-<16 hex>; never used for the data).

What is never collected#

Character names, realm names, account names, chat, whispers, guild, friends, other players' names, mail, auction or trade data, the player's typed text other than /psrec mark notes and /psrec trail labels, and anything from outside the game folder. Quest and gossip text has the player's name replaced with $N (and race and class with $R, $C) in the addon, and again in Polestar Client.

SavedVariables (PolestarRecorderDB, per account)#

lua
PolestarRecorderDB = {
	schema = 2,
	enabled = true,            -- recording on (/psrec on, or Polestar Client's share flag); nothing is written while false
	optout = true,             -- only after /psrec off: the client's share flag does not turn recording on; /psrec on clears it
	install = "k3j9x0q2m7ab",  -- 12 random base-36 characters, made once per WoW account folder
	seq = 41,                  -- last chunk number used
	acked = 37,                -- highest chunk Polestar Client confirmed (from Ack.lua), kept for display
	chunks = { [38] = "...", [39] = "...", [40] = "...", [41] = "..." },
	maps = { [1429] = "1429\t3\t1415\t0\t-7939.6\t1535.4\t-10135.2\t-2141.6", ... }, -- see `map` record
}

Per character (PolestarRecorderCharDB, SavedVariablesPerCharacter): { id = "<10 random base-36 characters>", textSeen = { [questID] = true } }. The character id lets records from one character be followed across sessions without its name.

Chunks#

  • A chunk is one Lua string: records separated by \n. A record is fields separated by \t. The first field is the record kind, the second dt.
  • The first record of every chunk is chunk, which sets the clock: chunk <dt=0> <seq> <serverEpoch> <charId>. Every later dt is tenths of a second since that epoch (integer).
  • A chunk closes after 5 minutes or 16 KiB, at logout, and on a character change. It is written into chunks[seq] when it closes and refreshed every 60 seconds out of combat (so a /reload or crash loses at most a minute). The open chunk is always the highest seq.
  • The addon keeps at most 4 MiB of chunks; the oldest unacknowledged ones go first and a dropped record counts them.
  • At login the addon deletes every chunk with seq <= PolestarRecorderAck[PolestarRecorderDB.install].

Ack file (written by Polestar Client)#

Interface/AddOns/Polestar_Recorder/Ack.lua, loaded first by the TOC. One entry per install, because several WoW accounts share one AddOns folder, then whether the player shares:

lua
PolestarRecorderAck = { ["k3j9x0q2m7ab"] = 37 }
PolestarRecorderShare = true

The release zip ships it as PolestarRecorderAck = nil with no share flag; Polestar Client merges its entry into whatever the file holds. A lost ack only means a chunk is uploaded twice; the server ignores a repeated (install, seq).

PolestarRecorderShare is written only once the player has turned sharing on in Polestar Client at least once (before that the file has no flag, so a /psrec on of someone who never opted in is left alone). From then on it is true while sharing is on and uploads are not paused, false otherwise; the client writes it whenever that changes and with every ack. At login the Recorder reads it: true turns recording on (saying so once in chat) unless optout is set, false turns it off (without setting optout), and no flag leaves enabled as the player set it. So one opt-in, Start sharing in the client, is enough; /psrec off stays the player's last word until /psrec on.

Field encoding#

  • Integers in decimal. Empty field = unknown or not applicable.
  • P = three fields map x y: C_Map uiMapID and the player's position in hundredths of a percent (integers 0–10000, about 0.3 yd on a zone). Empty when the client gives no position (instances).
  • Lists inside a field: items separated by ;, parts of an item by : (12345:2;6789:1).
  • Text fields (titles, names, objective text, notes) have \t, \n, ; and : replaced with spaces only where the field is a list; free text fields only lose \t and \n. Text is in the client's language (session.locale). At most 4,000 bytes per text field, and no control characters. Quest titles (text), objectives (objectives) and zone names (zone) also have no | (the game's escape character) and at most 255 characters (100 for zone names); a record that breaks this makes its chunk invalid.
  • npcKind: c creature, o game object, i item, p player (never stored beyond the kind), empty unknown.
  • Unknown record kinds are skipped by readers (forward compatible). A known kind with a malformed or extra field makes the whole chunk invalid; missing trailing optional fields read as empty (the addon always writes them all).

Record kinds#

Context

KindFields after dtWhen
chunkseq, serverEpoch, charIdfirst record of each chunk
sessioncharId, race, class, faction, sex, level, locale, build, interface, region, ruleset, recorderVersion, polestarVersion, packslogin, reload, every new chunk's second record. race/class are the client's file tokens (Scourge, WARRIOR); faction Horde/Alliance; build the build number only (70532); region from GetCurrentRegion; ruleset flags h hardcore, p PvP joined (the client has no API for RP realms); packs = id:version;... of Polestar guide packs loaded, plus guide:<id> of the active guide
mapid, type, parent, continent, top, left, bottom, rightonce per install per map seen (also kept in maps)
levellevel, P, playedSecondslevel up
xpxp, xpMax, restedevery 5 minutes and at level up
droppedcountafter chunks were dropped for size
markP, text/psrec mark <text>
Read the rest of this section603 words

Movement

KindFieldsWhen
posP, flags, speed25 yd and 2 s since the last, or 4 yd and 20 s; every 1 yd/0.25 s while /psrec trail is on. flags bits: 1 mounted, 2 swimming, 4 indoors, 8 flying, 16 falling, 32 dead or ghost, 64 trail sample. speed = yards/s ×10 when readable
zoneP, zoneText, subzoneTextmap or zone text changes
rideon (1/0), Pmoved while the player's own speed is 0 (boat, zeppelin, lift, tram) and when it stops
taxifromNode, toNodeflight takeoff (UnitOnTaxi confirmed); nodes are taxi node ids
landnode, seconds, Pflight landing
deathP, killerNpc, killerLeveldeath; killer = the last hostile creature that targeted or hit the player within 10 s, when readable
reviveP, how (s spirit healer, c corpse, r resurrected)alive again
combaton (1/0), Pentering and leaving combat
afkon (1/0)AFK flag changes
restkind (f food, d drink, b both), secondsan eat/drink aura ends

Quests

KindFieldsWhen
offernpcKind, npcId, P, available, active, log, done, notOffereda gossip, quest greeting or quest detail window opens. available/active = quest ids shown; log = quest ids in the log; done/notOffered = among the quests Polestar's data says this NPC starts (when Polestar is loaded), those flagged completed and those neither offered, in the log, nor completed
detailq, npcKind, npcId, P, startItemquest detail shown
textq, title, objectiveText, detailTextfirst time this character sees a quest's text (textSeen)
acceptq, npcKind, npcId, P, level, rewardXp, rewardMoney, items, choicesquest accepted. items/choices = itemId:count
objectivesq, listat accept: type:required:text per objective (type monster, item, object, event, reputation, log)
progressq, index, cur, max, Pan objective count changes
completeqall objectives done
turninq, npcKind, npcId, P, level, xp, money, chosenItemquest handed in (xp, money actually paid)
abandonqquest abandoned
gossipnpcKind, npcId, P, optionId, optionTexta gossip option is chosen
castspellId, itemId, P, targetKind, targetIdthe player uses a quest or usable item, or casts a spell that is not on the action bars' class spellbook (quest spells, summons); never combat rotation spells
escortq, on (1/0), Pan escort or event quest starts (timer or follower appears) and ends

Fights and loot

KindFieldsWhen
killnpcId, level, cls, P, xp, rested, tapped, qa creature the player or pet killed (PARTY_KILL, UNIT_DIED when readable, else the XP line). cls n normal, e elite, r rare, R rare elite, b boss; xp/rested from the XP message (client format strings, any language); tapped 1 when another player had the tag; q = questId:index when an objective ticked within 3 s
lootsrcKind, srcId, P, gather, items, wanteda loot window, one per source. gather h herb, m mine, s skin, f fish (fishing has an empty srcKind/srcId), empty corpse/chest; items = itemId:count (empty = nothing); wanted = questId:index of every item objective open at that moment (the drop-chance denominator)
unitnpcId, level, cls, react, P, srca creature seen on a nameplate, target or mouseover: once per (npcId, level) per session, and once per 100 yd per npcId per session. react h hostile, n neutral, f friendly; src p plate, t target, m mouseover
rarenpcId, level, cls, P, srca rare or rare elite sighted (src v vignette, p plate, t target, m mouseover)
treasureobjectId, Pa treasure vignette
enterinstanceId, instanceType, difficulty, Pentering an instance (P = last outdoor position)
encounterencounterId, ok (1/0), difficulty, sizeENCOUNTER_END

Services

KindFieldsWhen
trainernpcId, P, servicestrainer window: spellId:cost:reqLevel:state per service (state a available, u not yet, k known); spellId 0 + name when the client gives no id
vendornpcId, P, itemsmerchant window: itemId:price:stack:qty (qty -1 = unlimited)
flightmasternpcId, P, node, knowntaxi map opened: this node and the ids of every node this character knows
bindnpcId, Phearthstone bound
rep, skill, explore, learn, craft, achieveas schema 1, numeric ids first: rep factionId, standing, value, delta; skill skillLineId, rank, max; explore areaId, xp, P; learn spellId; craft recipeSpellId, itemId, count (only from the trade skill window); achieve id

Guide

KindFieldsWhen
stepguide, step, reason, PPolestar's step or guide changes (reason next, skip, back, auto (also at login and reload), pick, done)

Upload API (https://data.polestar.guide/v1)#

The server is reachable only through a Cloudflare Tunnel: it opens no ports and its address never appears in DNS.

Polestar Client registers once: it asks for a challenge, spends a few seconds of CPU on a proof of work, and receives a token, which it keeps on the PC. The server stores only the token's SHA-256, and calls it the contributor. Each network (an IPv4 address or an IPv6 /48) can register at most 3 times a UTC day (and one IPv6 /32 at most 20 times), and every registration from one network on one day shares a random cohort id. The server keeps only a keyed hash of the network, with a salt made for that day, and deletes both when the day ends.

Read the rest of this section654 words
RequestBody / answer
POST /v1/register{} → { "challenge" (32 hex), "difficulty" (22), "expires" }; then { "challenge", "solution" (u64 decimal) } (SHA-256 of challenge:solution has difficulty leading zero bits) → { "token" (64 hex) }. A challenge lasts 10 minutes and works once.
POST /v1/upload (Authorization: Bearer <token>){ "uploader": "0.1.0", "install": "<addon install id>", "chunks": [ { "seq": 38, "text": "..." } ] }. Answer { "accepted": [38], "duplicate": [], "rejected": [ { "seq": 39, "reason": "..." } ] }. Each chunk is decoded with the library; a chunk with any malformed record, or whose chunk record names another seq, is rejected whole. An unknown token gets 401 and registers again.
GET /v1/me (bearer){ "chunks", "records": { kind: n }, "first_upload", "last_upload" } (unix seconds): what the server holds from this token.
DELETE /v1/me (bearer)deletes everything the server holds from this token, counts included; answers { "deleted": <chunks> }. A tombstone then removes it from the maintainer's copies on their next pull.
GET /v1/statspublic totals: contributors, chunks, records by kind, quests seen, last 30 days (cached 60 s).
GET /v1/export?after=<id>&limit=<n>[&include=suspect] (maintainer key)NDJSON, one line per chunk: { "id", "contributor" (token hash, 16 hex), "cohort" (16 hex), "contributorSince", "contributorChunks" (chunks that passed the checks), "install", "seq", "received", "flagged", "text" }; with include=suspect also suspect chunks, with suspect and suspectReasons. after defaults to 0, limit to 500 (at most 1000); a page also stops after 8 MiB of chunk text (always at least one line).
GET /v1/export/tombstones?after=<unix> (maintainer key)NDJSON { "contributor", "at", "why": "deleted" | "blocked" }, oldest first; forge record pull purges those contributors' chunks and sessions.
POST /v1/export/ack { "upto": <id> } (maintainer key)every export line up to <id> is safely stored; answers { "acked", "pruned", "held" }.
GET /v1/admin/storage (maintainer key)database size and cap, free disk and floor, the oldest unacknowledged chunk's age, row counts.
GET /v1/admin/contributors, POST /v1/admin/contributors/<hash>/block, /unblock (maintainer key)moderation. A blocked contributor's uploads are handled like anyone's but never exported or counted in the stats.
GET /healthzok (503 when the database is stuck)

Limits: a body is at most 2 MiB and must arrive within 10 seconds; at most 32 bodies are read and 4 uploads processed at once (more get 503 and Retry-After). An upload holds at most 256 chunks; a chunk is at most 64 KiB of text and 4,096 records (Polestar Client skips a bigger chunk rather than cutting it). Records of unknown kinds are dropped from the stored chunk (Polestar Client leaves them out before sending); more than 64 reject the chunk. uiMapIDs are below 100,000. 20 MiB per contributor per UTC day (checked before the body is read, and counted on every byte received) and 60 MiB per cohort, 30 requests per minute per contributor, 120 per network, 10 a minute on /v1/register, 10 unknown-token answers a minute per network. Requests that did not come through the tunnel are refused. Chunks from builds older than the server's minimum build are stored with flagged, and forge skips them.

The server is a buffer, not the archive: forge record pull stores the export in data/recordings, then acknowledges it. Acknowledged chunks are deleted from the server 14 days after they arrived (stored compressed until then); the server keeps only counts (for /v1/me and /v1/stats) and which seqs it has seen, so a chunk sent again is still answered duplicate. When the database reaches its size cap (20 GiB) or its disk has under 25 GiB free, uploads are answered 503 {"error":"server busy, try later"} with Retry-After; Polestar Client keeps the chunks and retries later.

The plausibility checks: clock within the last 30 days and at most 10 minutes ahead, a chunk spans at most 2 hours, seq within 400 of the install's highest, levels 1–60, kill XP within what the level allows, at most 40 kills and 60 loot windows a minute, ids below 1,000,000, and at most 100 yards a second between positions on one map (map sizes from the game's own map data, never from the upload).

Trust#

Everything here is open source and anyone can edit their own file or write their own client, so nothing trusts a single upload.

At the server

Every chunk is decoded strictly and checked for things play can't produce: timestamps in the future or long past, time running backwards, impossible movement speed between positions, kill XP beyond what the level allows, more kills or loot windows a minute than a player can manage, ids out of range, text with control characters or game markup. A chunk that fails is kept aside as suspect and left out of the export. A contributor the maintainer blocks is answered like anyone else, but its held chunks are deleted and nothing it sends is used; the maintainer can also purge a contributor completely. Deleting your data, or being blocked, also removes it from the maintainer's copy at the next forge record pull.

Server-side signals

Every export line carries cohort, uploadDays and instance. cohort is a keyed hash of the network (IPv4 address or IPv6 /48) the contributor registered from, under a key that changes each UTC month, so tokens registered from one place in one month count as one voter. uploadDays is the number of distinct UTC days on which the server received plausible chunks from the contributor, counted by the server, not claimed by the client. instance identifies the server's database; when it changes (a restore or a new database), forge record pull starts again from the first export id.

In the data build

A voter is a cohort (data pulled before cohorts existed counts per contributor, resolved across all its sessions). A contributor counts only once established: uploadDays at least 3, at least 20 chunks sent, and sessions with real play (positions and kills). local (the maintainer's own) and sent-* (a file the maintainer vouched for) are trusted outright; anon-* (a sent-in file nobody vouched for) and contributors not yet established are not used at all. A voter counts once per fact, however many installs, tokens or sessions it has. Every value a recording sets goes through the same rule: a value another source already has (a quest's title, level, objectives, giver or ender, rewards, XP, an NPC's name, rank or level, a spawn, a learned path, a trainer's list, a drop chance) changes only on the evidence of 3 distinct counted voters (for a drop chance, each with 10 kills of their own), or of local or sent-*; one counted voter fills only gaps no other source has. Objectives are added or reordered only on trusted evidence. New quests, NPCs, objects, items, factions and zones come only from local or sent-*, and map frames only from local (for maps the game's own data lacks). Numbers several voters recorded are the median of each voter's own value, never a pooled total. Each field is labeled by where it came from: recorded on trusted evidence, or a gap one voter filled (which the public database never publishes on its own). Text from a non-English client never overwrites English names.

What the server keeps

Rows per contributor are capped: 5,000 quests, 8 installs, about 1,000 remembered seqs per install, and 30 days of per-day counts. Backups are encrypted, hold neither the day's network salt nor registration counts, and are kept 7 days on the server and 30 days off-site (locked against deletion for the first 14); deleted data is gone from every backup 30 days after deletion.
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