{
  "SaveName": "",
  "Date": "",
  "VersionNumber": "",
  "GameMode": "",
  "GameType": "",
  "GameComplexity": "",
  "Tags": [],
  "Gravity": 0.5,
  "PlayArea": 0.5,
  "Table": "",
  "Sky": "",
  "Note": "",
  "TabStates": {},
  "LuaScript": "",
  "LuaScriptState": "",
  "XmlUI": "",
  "ObjectStates": [
    {
      "GUID": "a07d4w",
      "Name": "BlockTriangle",
      "Transform": {
        "posX": -44.34724,
        "posY": 12.579423,
        "posZ": -2.363408,
        "rotX": 0.0,
        "rotY": 90.0,
        "rotZ": 0.0,
        "scaleX": 3.49999738,
        "scaleY": 3.49999738,
        "scaleZ": 3.49999738
      },
      "Nickname": "Spectator Tool Autodraw",
      "Description": "Spectator 13 plus XML UI capture. PARKED EXPERIMENT -- see tts/README.md (the Build) before running it against a live room. AUTODRAW VARIANT: pressing Broadcast on a table with no hand-drawn SpectatorTool zone spawns one fitted around everything on the table, and Disable (or unloading, or deleting this tool) removes it again; the tool itself and every card in a player's hand are left out of what is published.",
      "GMNotes": "",
      "AltLookAngle": {
        "x": 0.0,
        "y": 0.0,
        "z": 0.0
      },
      "ColorDiffuse": {
        "r": 0.00539905,
        "g": 0.772058845,
        "b": 0.0
      },
      "LayoutGroupSortIndex": 0,
      "Value": 0,
      "Locked": true,
      "Grid": true,
      "Snap": true,
      "IgnoreFoW": false,
      "MeasureMovement": false,
      "DragSelectable": true,
      "Autoraise": true,
      "Sticky": true,
      "Tooltip": true,
      "GridProjection": false,
      "HideWhenFaceDown": false,
      "Hands": false,
      "LuaScript": "-- ============================================================\n-- TTS Spectator Broadcaster (HANDS + ZONES) -- SPECTATOR 13\n--  - FULL + DIFF (PERF-OPTIMIZED + ROUND-ROBIN)\n--  - Original features preserved (buttons, peek, counters, images, RR cache)\n--  - Sends {type=\"diff\"} most of the time, and periodic {type=\"full\"}\n--  - v13 vs v12: 500ms cadence (0.25 s since 2026-09-07, see POLL_SECONDS),\n--    no forced-publish tick, diff truncation fix, hand-diff pos, REVEAL_HIDDEN\n--    mode, /update/<code> + Bearer writeToken.\n--  - 2026-08-21: dead-GUID cache sweep in rebuildRoundRobinLists (fixes memory\n--    growth -> progressive lag in long games; see scripts/simharness/).\n-- ============================================================\n\n-- =========================\n-- CONFIG\n-- =========================\nlocal WORKER_BASE = \"https://tts-spectator.bigytimes.workers.dev\"\nlocal CREATE_URL  = WORKER_BASE .. \"/create\"\n\nlocal ZONE_NAME_PREFIX = \"SpectatorTool\"\nlocal ZONE_NAME_DELIM  = \":\"\n\n-- 0.25 s TICK (Spectator Tool Autodraw build only, 2026-09-07). The user's\n-- deliberate experiment: half the tick, so a change reaches the site twice as\n-- fast. Nothing else is rescaled -- the round-robin budgets are per TICK, so\n-- they now do twice as much work per second on purpose, and MIN_POST_INTERVAL\n-- (0.2 s, the next declaration) lets every tick post, so the tick, not the\n-- interval, bounds the post rate. See tts/lua/config-and-state/README.md. The plain\n-- build, which kept 0.5 s, was retired on 2026-09-26.\nlocal POLL_SECONDS = 0.25\n-- 0.2 s (Spectator Tool Autodraw build only, 2026-09-07). At 0.5 s against a\n-- 0.25 s poll every second tick was turned away here, which is what made the\n-- FROZEN STATE bug fire constantly (see the module docstring). 0.2 lets every\n-- tick post, so the post rate is bounded by the tick, and a tick that is still\n-- blocked -- by an in-flight post -- simply flushes on the next one.\n-- The plain build, which kept 0.5 s, was retired on 2026-09-26.\nlocal MIN_POST_INTERVAL = 0.2\nlocal FIRST_PUBLISH_DELAY = 1.0\n\n-- When false, hidden information is redacted from payloads (see itemForObject /\n-- itemForHandObj / container peeks). Toggling forces a full snapshot.\nlocal REVEAL_HIDDEN = true\n\nlocal RETRY_DELAY = 2.0\n-- MAX_RETRY_ATTEMPTS is a BACKOFF threshold, not a give-up threshold: after this\n-- many consecutive NETWORK errors the retry keeps going at RETRY_SLOW_DELAY\n-- instead of stopping. See scheduleRetry for why stopping is never acceptable.\nlocal MAX_RETRY_ATTEMPTS = 3\nlocal RETRY_SLOW_DELAY = 15.0\n\nlocal MAX_CONTAINER_PEEK = 80\nlocal POS_DECIMALS = 3\nlocal ROT_DECIMALS = 0\nlocal POS_EPS = 0.03\n\n-- Caching / throttling\n-- Periodic zone rescan. onObjectSpawn/onObjectDestroy (bottom of file) already\n-- invalidate the cache instantly when a SpectatorTool zone is created/deleted, but\n-- this backstop is NOT redundant: TTS fires no event when an object is RENAMED, so\n-- a zone renamed INTO or OUT OF the \"SpectatorTool\" prefix is only picked up by this\n-- periodic rescan. Do not remove it.\nlocal ZONE_RESCAN_SECONDS = 10.0\n\nlocal BTN_REFRESH_ACTIVE_SECONDS   = 2.0\nlocal BTN_REFRESH_INACTIVE_SECONDS = 10.0\n\n-- name/scale changes are rare, so re-read them far less often than buttons; a\n-- rename/rescale then surfaces within ~META_REFRESH_SECONDS + one RR sweep\n-- instead of instantly.\nlocal META_REFRESH_SECONDS = 12.0\n\nlocal PEEK_REFRESH_SECONDS = 5.0\n\n-- Fragment-cache TTL: the self-heal for item content that escapes lightObjSig\n-- (renames, scale changes, shuffle-reordered deck previews, button color/\n-- position edits). Such drift persists a bounded time instead of forever.\n-- NOTE: the effective refresh cadence is governed by the per-full budget\n-- below; TTL expiry only marks a fragment as a refresh CANDIDATE.\nlocal FRAG_TTL_SECONDS = 1800.0\n\n-- Max TTL-expired same-sig fragments re-encoded per full build. Fragments for\n-- static objects are all created together during the first full, so they also\n-- expire together -- without a budget, one full would re-encode every static\n-- item in a single frame and the stampede would return every other full.\nlocal FRAG_REFRESH_BUDGET_PER_FULL = 25\n\n-- Round-robin invalidation runs every poll; RR-updated caches get picked up on\n-- the next real cheap-signature change (no forced-publish tick in v13).\n\n-- Round-robin budgets (tune these)\n-- HALVED (Spectator Tool Autodraw build only, 2026-09-12): the round-robin\n-- steps on EVERY tick now, not only on a full one, so half the budget twice\n-- is the same rate.\nlocal RR_BTN_BUDGET  = 6\nlocal RR_PEEK_BUDGET = 1\n\n-- =========================\n-- DIFF CONFIG (NEW)\n-- =========================\nlocal DIFF_ENABLED = true\n-- The Durable Object holds canonical state and a seq-gap -> 409 -> full is the\n-- designed recovery path, so periodic fulls are only belt-and-braces: rare.\nlocal FULL_SNAPSHOT_SECONDS = 600.0\n\n-- safety caps\nlocal MAX_DIFF_OBJECT_UPDATES = 250\nlocal MAX_DIFF_OBJECT_ADDS    = 250\nlocal MAX_DIFF_OBJECT_REMOVES = 500\n\n-- =========================\n-- DEBUG / PROFILING CONFIG\n-- =========================\nlocal DEBUG_ENABLED = false\nlocal DEBUG_LOG_SECONDS = 10.0\nlocal DEBUG_SLOW_MS = 8.0\nlocal DEBUG_MAX_SLOW_ITEMS = 8\n\n-- =========================\n-- INTERNAL STATE\n-- =========================\nlocal broadcasting = false\nlocal roomCode = nil\nlocal writeToken = nil\n\nlocal inFlight = false\nlocal lastPostAt = 0\n-- Counts CONSECUTIVE network errors only (req.is_error). Any HTTP response --\n-- including 409 / 413 / 500 -- clears it, because a server that answered proves\n-- the network works; only unanswered posts deserve backoff. (404/401 never get\n-- this far: they stop broadcasting outright -- see publishIfNeeded's callback.)\nlocal retryAttempts = 0\n-- Exactly one retry timer may be armed at a time. pollLoop can publish (and\n-- fail) while a retry is already pending, and without this guard those timers\n-- multiply into a hot loop of forced full snapshots.\nlocal retryPending = false\n-- Rate-limit for the \"payload build failed\" chat line (one per 10s). A build\n-- that keeps failing must say so once, not once per 0.5s poll.\nlocal nextBuildFailLogAt = 0\n\n-- FORWARD DECLARATION. setButtonLabels is DEFINED far below (with the other UI\n-- code, next to the colors and indexes it depends on), but the UPDATE response\n-- callback in publishIfNeeded has to repaint the panel when it turns broadcasting\n-- off on a terminal status. A Lua local is invisible above its own declaration,\n-- so calling it from up there without this line would silently read a nil GLOBAL\n-- and blow up at runtime. Declaring the name here and writing the body later with\n-- `function setButtonLabels()` (NOT `local function`) makes every reference --\n-- earlier and later -- resolve to this one local.\n-- (publishIfNeeded solves the same problem by being a plain global; a local is\n-- preferred for new code because TTS shares _G across every script in the save,\n-- and only click_function handlers actually need to live there.)\nlocal setButtonLabels\n\n-- lastSeenSig / lastAckSig track CHEAP signature\nlocal lastSeenSig = nil\nlocal lastAckSig  = nil\n\n-- Zone caching\nlocal CACHED_ZONES = nil\nlocal nextZoneRescanAt = 0\n-- Set by onObjectSpawn/onObjectDestroy so a created/deleted zone invalidates the\n-- cache immediately instead of waiting out the ZONE_RESCAN_SECONDS window.\nlocal ZONES_DIRTY = false\n\n-- Button cache by object GUID\nlocal BTN_CACHE = {}   -- [guid] = { buttons=nil|table, hasButtons=bool, nextAt=number }\n\n-- Peek cache by container GUID\nlocal PEEK_CACHE = {}  -- [guid] = { peek=nil|table, nextAt=number, populated=bool }\n\n-- Fragment cache by object GUID: pre-encoded per-object JSON so a periodic full\n-- re-encodes only what changed (most objects are unchanged between fulls).\nlocal FRAG_CACHE = {}  -- [guid] = { sig=string, json=string, expireAt=number }\n\n-- Shuffle/randomize epoch by object GUID: bumped by onObjectRandomize. A shuffle\n-- reorders container contents without moving the object, so no polled property\n-- changes; folding this counter into the signatures flips them so the deck\n-- re-publishes with a fresh top-card preview and peek.\nlocal SHUFFLE_EPOCH = {}  -- [guid] = bump count from onObjectRandomize\n\n-- Flags used during payload build (kept for compatibility; usually false now)\nlocal FORCE_BUTTON_REFRESH = false\nlocal FORCE_PEEK_REFRESH   = false\n\n-- Round-robin lists\nlocal RR_OBJECTS = {}\nlocal RR_CONTAINERS = {}\n-- Round-robin bookkeeping (Spectator 13 XML build only), one table so it costs\n-- one top-level local:\n--   guids     -- parallel to RR_OBJECTS above: same index = same object.\n--   contGuids -- parallel to RR_CONTAINERS, same rule.\n--   (no `dead` set in this build: a death is COUNTED once, on AUTO, and the\n--                walker asks whether this entry died AFTER the lists captured\n--                it -- see THE ONE DEAD RECORD.)\n--   visited   -- [guid] = true for every entry visited in the CURRENT lap;\n--                survives rebuilds on purpose (the lap is about objects, not\n--                list slots); emptied by rrNextIdx when a full pass finds\n--                nothing left.\n--   contVisited -- the same, for the container lap.\nlocal RR_META = { guids = {}, contGuids = {}, visited = {}, contVisited = {} }\n\n-- Autodraw bookkeeping (Spectator Tool Autodraw build only), one table so it\n-- costs one top-level local -- the chunk is close to Lua's 200-per-scope limit\n-- (a named rule in tts/build/rules.json pins the count):\n--   zoneGuid -- GUID of the zone we spawned, or nil when we did not spawn one\n--               (the user had drawn their own, or broadcasting is off).\n--   excl     -- [guid] = true for everything the five zone consumers must NOT\n--               see: this tool, and every object in any player's hand. Rebuilt\n--               at the top of every poll tick by AUTO.refreshExcl.\n--   selfGuid -- read once in onLoad; the tool cannot ask for its own GUID from\n--               inside a plain function on every tick without paying for it.\n--   setup    -- the \"Setting up...\" warm-up state: active, the {obj, guid} work\n--               list and the death count that list was captured at (`at`), how\n--               far through it we are, the totals and the percentage on the\n--               Broadcast button, plus what the FRAME LOOP needs --\n--               frames, frameAt (when the last frame callback fired, which is\n--               what the watchdog reads) and gen (the generation, so a stale\n--               callback can tell it belongs to a loop that is over). Declared\n--               inactive here so AUTO.setup.active can be read on the very\n--               first tick. Two transient fields ride beside it while the\n--               warm-up runs (2026-09-08, print-only): setupJson, which\n--               jencTimed adds each encode's cost into, and setupPhase, the\n--               meta / btn / frag / json split of the object AUTO.setupOne has\n--               just finished, which AUTO.setupNote copies onto a slow one.\n--   tick     -- poll ticks since the warm-up ended. Since the split scan\n--               (2026-09-12) an ODD tick is tick A (the identity pass and the\n--               first half of every zone) and an EVEN tick is tick B (the\n--               second half, the signature and the publish decision); neither\n--               is a cheap hot tick, and both sample the hot set.\n--   hot      -- [guid] = { o = ref, zg = zone guid or nil, [\"until\"] = deadline,\n--               at = stamp }: the objects AUTO.hotStep samples. `at` is the death\n--               count at the moment `o` was captured, which is what says whether\n--               the reference is still safe to touch (THE ONE DEAD RECORD\n--               below). An entry lives one second past the last movement seen.\n--   hotSig   -- [guid] = the movement-only part of the change gate's per-object\n--               string, so \"did this move since last time\" is a compare, not a\n--               TTS call. Pruned at every round-robin rebuild.\n--   hotPending -- a hot object moved and has not been published yet.\n--   fullPending -- an object DIED (AUTO.markDeadGuid) and no ordinary,\n--               whole-table diff has gone out since. Only tick B's ordinary diff\n--               emits a `remove`, so this keeps the publish gate open until one\n--               does -- otherwise a token that a FLIGHT post put on the board\n--               and that then went back into a bag stays there for good. The\n--               same \"one ordinary diff owed\" debt is raised by AUTO.forceEmit\n--               (2026-09-17) and by the Spectator View button (2026-09-28).\n--   deathSeq / deathAt / rrAt / scanAt -- THE ONE DEAD RECORD (2026-09-13).\n--               deathSeq counts deaths and is never reset; deathAt[guid] is the\n--               count at that object's most recent death; rrAt and scanAt are the\n--               counts at which the round-robin lists and the scan arrays\n--               captured their references. A reference is safe to touch when it\n--               was captured AFTER the object last died, and AUTO.deadRef is that\n--               one comparison. Hot entries, flight-pending records and the\n--               warm-up list carry their own `at` for the same reason;\n--               AUTO.deathPrune drops a record once every stamp is at or past it.\n--   diffEmpty -- set by buildDiffSnapshot on a diff -- HOT or ordinary -- that\n--               turned out to carry nothing at all: no zone add, update, remove\n--               or meta change, no hand change, no asset table. publishIfNeeded\n--               hands the seq back and posts nothing (2026-09-12; hot payloads\n--               too since 2026-09-13). Reset to false at the top of every\n--               buildPayload, so it can never be read stale. A FULL is never\n--               skipped: its branch returns long before the flag is set, and\n--               publishIfNeeded tests `not force` as well. What the SKIP settles\n--               does depend on the kind -- see publishIfNeeded.\n--   gateSig  -- [guid] = the cheap scan's FINISHED per-object string (pose,\n--               buttons, rr metadata, container quantity, shuffle epoch).\n--               Written by the scan, read by the ordinary diff, so an object\n--               nothing has changed costs no engine call there at all.\n--   diffGate -- [guid] = the gateSig string under which that object's\n--               lightObjSig was last computed. Equal to the current one means\n--               \"nothing the scan can see has moved since\", which is what lets\n--               the diff reuse the previous signature. Both maps are pruned at\n--               the 10 s rebuild (AUTO.gatePrune) and a missing entry only ever\n--               costs a re-read.\n--   scanG / scanO / scanN -- what the cheap scan SAW, per zone: [zg] = a flat\n--               array of GUIDs, [zg] = the matching array of object references,\n--               and [zg] = how many entries of each are good. The ordinary diff\n--               walks these instead of re-reading the zone, so it makes no\n--               getObjects and no getGUID of its own. The two arrays are the\n--               same tables every tick -- overwritten in place, never rebuilt --\n--               and entries past the count are last tick's and must be ignored.\n--               A zone with no count (first tick, or a zone that just appeared)\n--               sends the diff back to AUTO.filtered for that zone alone.\n--   scanGen  -- [zg] = a count the identity pass bumps whenever that zone's\n--               membership changes (p3, 2026-09-21).\n--   zoneOrder -- [zg] = that zone's sorted GUID order, built once per scanGen\n--               (AUTO.zoneOrderBuild), so AUTO.spreadZonesSig needs no sort.\n--   lapCursor -- where the rotating re-read is up to in the diff's walk order.\n--               AUTO.LAP_PER_DIFF objects an ordinary diff are read for real\n--               whatever the gate says, so counter / tint / sub-0.03 movement\n--               -- the three things gateSig cannot see -- self-heal (a flip moves\n--               the scan string: all three rotation axes are in it, 2026-09-08).\n--   flightPending -- [guid] = { o = ref, at = stamp, tries = n }: objects a\n--               spawn or a leave-container event handed over, waiting for TTS to\n--               admit where their smooth move is going. `at` is the death count\n--               at which the event handed the reference over, so the record\n--               proves its own reference. Read for at most AUTO.FLIGHT_TRIES\n--               frames and then dropped.\n--   flightLoop -- { gen, armed }: the ONE-SHOT Wait.frames that reads them.\n--               `armed` coalesces a burst of events into one callback; `gen`\n--               is bumped at room create and in AUTO.destroy so a callback\n--               from the previous broadcast returns without working.\n--   dataMemo -- set for the length of ONE fragment build and nil the rest of\n--               the time: { guid, data, read }, so the three or four\n--               obj.getData() reads a cold deck used to make become one. It\n--               must never outlive the call, or the next build of the same\n--               object would be handed a stale serialisation.\n--   deckRoster -- THE BIG DECK ROSTER (2026-09-17): [deck guid] = { ids, cd,\n--               dirty, dirtyAt, readAt, n }. `ids` is a copy of that deck's\n--               DeckIDs -- card numbers BY POSITION, which is the only thing a\n--               contained card can be identified by -- and `cd` a reference to\n--               its CustomDeck sheet map, both filled by ONE deliberate\n--               getData per change burst (AUTO.rosterService, on tick A).\n--               One entry per big DECK and none at all on most tables; dropped\n--               by AUTO.markDeadGuid with everything else keyed on the guid.\n--   hotOnly  -- set ONLY around a hot publish: AUTO.filtered then answers with\n--               the zone's hot objects and buildDiffSnapshot carries every\n--               other baseline entry forward untouched.\n--   name     -- the auto zone's name, derived from the zone-naming constants.\n-- Every function hangs off this table too (AUTO.fit, AUTO.spawn, ...), defined\n-- further down where the helpers they call are already in scope.\nlocal AUTO = { zoneGuid = nil, excl = {}, selfGuid = \"\",\n               setup = { active = false },\n               tick = 0, hot = {}, hotSig = {}, hotPending = false, hotOnly = false,\n               fullPending = false,\n               deathSeq = 0, deathAt = {}, rrAt = 0, scanAt = 0,\n               diffEmpty = false,\n               gateSig = {}, diffGate = {}, lapCursor = 0,\n               scanG = {}, scanO = {}, scanN = {}, scanGen = {}, zoneOrder = {},\n               flightPending = {}, flightLoop = { gen = 0, armed = false },\n               dataMemo = nil,\n               deckRoster = {},\n               name = ZONE_NAME_PREFIX .. ZONE_NAME_DELIM .. \"Autodraw\" }\nlocal rrObjIdx = 1\nlocal rrContIdx = 1\nlocal nextRRRebuildAt = 0\nlocal RR_REBUILD_SECONDS = 10.0\n\n-- =========================\n-- DIFF STATE (NEW)\n-- =========================\nlocal DIFF_SEQ = 0\nlocal nextFullSnapshotAt = 0\n-- zoneGuid -> { metaSig=string, objs = { [objGuid]=sigString } }\nlocal LAST_ZONE_STATE = {}\n-- playerColor -> { hand = { [objGuid]=sigString }, order = { [objGuid]=index } }\nlocal LAST_HAND_STATE = {}\n\n-- =========================\n-- DEBUG / PROFILING STATE\n-- =========================\nlocal PROF = {\n  nextLogAt = 0,\n\n  polls = 0,\n  publishesAttempted = 0,\n  publishesSent = 0,\n  publishesAck200 = 0,\n  publishesErr = 0,\n  -- Diffs built, found to carry nothing, and NOT posted -- hot\n  -- payloads and ordinary ones, counted together (Spectator Tool\n  -- Autodraw build only, 2026-09-12; hot too since 2026-09-13).\n  publishesEmpty = 0,\n  -- Presence posts sent on their own, and state posts that\n  -- carried presence fields for free (Spectator Tool Autodraw\n  -- build only, 2026-09-15). The second should be much the\n  -- bigger number on a table anybody is playing at.\n  presStandalone = 0,\n  presPiggy = 0,\n  -- THE TABLE'S INK (Spectator Tool Autodraw build only, 2026-09-18):\n  -- reads taken, milliseconds spent in them, and payloads that\n  -- carried the ink. On a table nobody draws on the first is one\n  -- every 8 ticks, the second is a fraction of a millisecond and the\n  -- third is 0.\n  drawScans = 0,\n  drawScanMs = 0,\n  drawPublishes = 0,\n  retries = 0,\n\n  zones = 0,\n  zoneObjs = 0,\n  handPlayers = 0,\n  handObjs = 0,\n\n  rrRebuilds = 0,\n  rrBtnRefreshes = 0,\n  rrPeekInvalidations = 0,\n\n  btnHits = 0,\n  btnMisses = 0,\n  btnRawCalls = 0,\n  btnRawButtonsTotal = 0,\n  inputRawCalls = 0,\n  decalRawCalls = 0,\n\n  peekHits = 0,\n  peekMisses = 0,\n  peekRawCalls = 0,\n  peekRawItemsTotal = 0,\n\n  fragHits = 0,\n  fragMisses = 0,\n  fragStale = 0,\n\n  cacheSweepRemoved = 0,\n\n  t_poll_total = 0,\n  t_zonecache = 0,\n  -- The hand-zone read (Spectator Tool Autodraw build only, 2026-09-18):\n  -- one seat walk per zone-cache rescan, so at most one per 10 s.\n  t_handzones = 0,\n  t_rr_rebuild = 0,\n  t_rr_steps = 0,\n  t_sig = 0,\n  t_publish_gate = 0,\n  t_payload = 0,\n  t_json = 0,\n  t_webreq = 0,\n  t_update_cb = 0,\n\n  slow = {\n    poll = {},\n    zone = {},\n    payload = {},\n    json = {},\n    buttons = {},\n    peeks = {},\n    webcb = {},\n  }\n}\n\nlocal function ms(dt) return (dt or 0) * 1000.0 end\nlocal function profAdd(name, dtSeconds)\n  if not DEBUG_ENABLED then return end\n  PROF[name] = (PROF[name] or 0) + ms(dtSeconds)\nend\n\nlocal function profSlowPush(bucket, label, dtSeconds)\n  if not DEBUG_ENABLED then return end\n  local tms = ms(dtSeconds)\n  if tms < DEBUG_SLOW_MS then return end\n  local arr = PROF.slow[bucket]\n  if not arr then return end\n  arr[#arr+1] = { ms = tms, label = label }\n  if #arr > DEBUG_MAX_SLOW_ITEMS then\n    table.sort(arr, function(a,b) return a.ms > b.ms end)\n    while #arr > DEBUG_MAX_SLOW_ITEMS do table.remove(arr) end\n  end\nend\n\nlocal function profResetWindow()\n  PROF.polls = 0\n  PROF.publishesAttempted = 0\n  PROF.publishesSent = 0\n  PROF.publishesAck200 = 0\n  PROF.publishesErr = 0\n  PROF.publishesEmpty = 0\n  PROF.presStandalone = 0\n  PROF.presPiggy = 0\n  PROF.drawScans = 0\n  PROF.drawScanMs = 0\n  PROF.drawPublishes = 0\n  PROF.retries = 0\n\n  PROF.zones = 0\n  PROF.zoneObjs = 0\n  PROF.handPlayers = 0\n  PROF.handObjs = 0\n\n  PROF.rrRebuilds = 0\n  PROF.rrBtnRefreshes = 0\n  PROF.rrPeekInvalidations = 0\n\n  PROF.btnHits = 0\n  PROF.btnMisses = 0\n  PROF.btnRawCalls = 0\n  PROF.btnRawButtonsTotal = 0\n  PROF.inputRawCalls = 0\n  PROF.decalRawCalls = 0\n\n  PROF.peekHits = 0\n  PROF.peekMisses = 0\n  PROF.peekRawCalls = 0\n  PROF.peekRawItemsTotal = 0\n\n  PROF.fragHits = 0\n  PROF.fragMisses = 0\n  PROF.fragStale = 0\n\n  PROF.cacheSweepRemoved = 0\n\n  PROF.t_poll_total = 0\n  PROF.t_zonecache = 0\n  PROF.t_handzones = 0\n  PROF.t_rr_rebuild = 0\n  PROF.t_rr_steps = 0\n  PROF.t_sig = 0\n  PROF.t_publish_gate = 0\n  PROF.t_payload = 0\n  PROF.t_json = 0\n  PROF.t_webreq = 0\n  PROF.t_update_cb = 0\n\n  PROF.slow.poll = {}\n  PROF.slow.zone = {}\n  PROF.slow.payload = {}\n  PROF.slow.json = {}\n  PROF.slow.buttons = {}\n  PROF.slow.peeks = {}\n  PROF.slow.webcb = {}\nend\n\nlocal function fmtMs(v) return string.format(\"%.2f\", v or 0) end\n\nlocal function printSlowBucket(title, arr)\n  if not arr or #arr == 0 then return end\n  table.sort(arr, function(a,b) return a.ms > b.ms end)\n  print(\"  \" .. title .. \" (top \" .. tostring(#arr) .. \"):\")\n  for i = 1, #arr do\n    local it = arr[i]\n    print(\"    - \" .. string.format(\"%.2fms\", it.ms) .. \" :: \" .. tostring(it.label))\n  end\nend\n\nlocal function debugPrintProfileSummary(force)\n  if not DEBUG_ENABLED and not force then return end\n  local now = os.clock()\n  if not force and now < (PROF.nextLogAt or 0) then return end\n  PROF.nextLogAt = now + DEBUG_LOG_SECONDS\n\n  local polls = math.max(1, PROF.polls)\n\n  print(\"====== [Spectator PROF] window=\" .. tostring(DEBUG_LOG_SECONDS) .. \"s ======\")\n  print(\"  polls=\" .. tostring(PROF.polls) ..\n        \" | publishesAttempted=\" .. tostring(PROF.publishesAttempted) ..\n        \" | sent=\" .. tostring(PROF.publishesSent) ..\n        \" | ack200=\" .. tostring(PROF.publishesAck200) ..\n        \" | err=\" .. tostring(PROF.publishesErr) ..\n        \" | empty=\" .. tostring(PROF.publishesEmpty) ..\n        \" | pres=\" .. tostring(PROF.presStandalone) .. \"/\" .. tostring(PROF.presPiggy) ..\n        \" | draw=\" .. tostring(PROF.drawScans) .. \"/\" .. tostring(PROF.drawPublishes) ..\n        \" | retries=\" .. tostring(PROF.retries))\n\n  print(\"  zones=\" .. tostring(PROF.zones) ..\n        \" | zoneObjs=\" .. tostring(PROF.zoneObjs) ..\n        \" | players=\" .. tostring(PROF.handPlayers) ..\n        \" | handObjs=\" .. tostring(PROF.handObjs))\n\n  print(\"  RR rebuilds=\" .. tostring(PROF.rrRebuilds) ..\n        \" | rrBtnRef=\" .. tostring(PROF.rrBtnRefreshes) ..\n        \" | rrPeekInv=\" .. tostring(PROF.rrPeekInvalidations))\n\n  print(\"  BTN hits=\" .. tostring(PROF.btnHits) ..\n        \" | misses=\" .. tostring(PROF.btnMisses) ..\n        \" | rawCalls=\" .. tostring(PROF.btnRawCalls) ..\n        \" | rawBtnsTotal=\" .. tostring(PROF.btnRawButtonsTotal) ..\n        \" | inputRaw=\" .. tostring(PROF.inputRawCalls) ..\n        \" | decalRaw=\" .. tostring(PROF.decalRawCalls))\n\n  print(\"  PEEK hits=\" .. tostring(PROF.peekHits) ..\n        \" | misses=\" .. tostring(PROF.peekMisses) ..\n        \" | rawCalls=\" .. tostring(PROF.peekRawCalls) ..\n        \" | rawItemsTotal=\" .. tostring(PROF.peekRawItemsTotal))\n\n  print(\"  FRAG hits=\" .. tostring(PROF.fragHits) ..\n        \" | misses=\" .. tostring(PROF.fragMisses) ..\n        \" | stale=\" .. tostring(PROF.fragStale) ..\n        \" | sweptDead=\" .. tostring(PROF.cacheSweepRemoved))\n\n  print(\"  timing(ms): poll_total=\" .. fmtMs(PROF.t_poll_total) ..\n        \" (\" .. fmtMs(PROF.t_poll_total / polls) .. \"/poll)\" ..\n        \" | zonecache=\" .. fmtMs(PROF.t_zonecache) ..\n        \" | handzones=\" .. fmtMs(PROF.t_handzones) ..\n        \" | drawscan=\" .. fmtMs(PROF.drawScanMs) ..\n        \" | rr_rebuild=\" .. fmtMs(PROF.t_rr_rebuild) ..\n        \" | rr_steps=\" .. fmtMs(PROF.t_rr_steps) ..\n        \" | sig=\" .. fmtMs(PROF.t_sig) ..\n        \" | publish_gate=\" .. fmtMs(PROF.t_publish_gate) ..\n        \" | payload=\" .. fmtMs(PROF.t_payload) ..\n        \" | json=\" .. fmtMs(PROF.t_json) ..\n        \" | update_cb=\" .. fmtMs(PROF.t_update_cb))\n\n  printSlowBucket(\"SLOW pollLoop\", PROF.slow.poll)\n  printSlowBucket(\"SLOW zones\", PROF.slow.zone)\n  printSlowBucket(\"SLOW payload\", PROF.slow.payload)\n  printSlowBucket(\"SLOW json\", PROF.slow.json)\n  printSlowBucket(\"SLOW buttons(getButtons)\", PROF.slow.buttons)\n  printSlowBucket(\"SLOW peeks(containerPeekRaw)\", PROF.slow.peeks)\n  printSlowBucket(\"SLOW web callbacks\", PROF.slow.webcb)\n\n  print(\"=============================================================\")\n  profResetWindow()\nend\n\n-- =========================\n-- UTILS\n-- =========================\nlocal function startsWith(s, prefix)\n  if not s or not prefix then return false end\n  s = tostring(s); prefix = tostring(prefix)\n  return s:sub(1, #prefix) == prefix\nend\n\nlocal function safeStr(s)\n  if s == nil then return \"\" end\n  s = tostring(s)\n  return s:gsub(\"\\n\", \" \"):gsub(\"\\r\", \" \")\nend\n\nlocal function normalizeHttps(u)\n  if not u or u == \"\" then return u end\n  return (string.gsub(u, \"^http://\", \"https://\"))\nend\n\nlocal function roundN(x, n)\n  if x == nil then return 0 end\n  n = n or 0\n  local m = 10 ^ n\n  return math.floor(x * m + 0.5) / m\nend\n\nlocal function vec3Round(v, n)\n  if not v then return { x=0, y=0, z=0 } end\n  return { x = roundN(v.x or 0, n), y = roundN(v.y or 0, n), z = roundN(v.z or 0, n) }\nend\n\nlocal function rotRound(r, n)\n  if not r then return { x=0, y=0, z=0 } end\n  return { x = roundN(r.x or 0, n), y = roundN(r.y or 0, n), z = roundN(r.z or 0, n) }\nend\n\nlocal function objScaleRound(obj, n)\n  if not obj then return nil end\n  local ok, sc = pcall(function() return obj.getScale() end)\n  if ok and sc then\n    local s2 = vec3Round(sc, n or POS_DECIMALS)\n    return { x = s2.x, y = s2.y, z = s2.z }\n  end\n  return nil\nend\n\nlocal function objBoundsRound(obj, n)\n  if not obj then return nil end\n  local ok, b = pcall(function() return obj.getBoundsNormalized() end)\n  if ok and b then\n    local s2 = vec3Round(b.size, n or POS_DECIMALS)\n    return { x = s2.x, y = s2.y, z = s2.z }\n  end\n  return nil\nend\n\nlocal function safeName(obj)\n  if not obj then return \"\" end\n  local ok, n = pcall(function() return obj.getName() end)\n  if ok and n and n ~= \"\" then return n end\n  local ok2, d = pcall(function() return obj.getDescription() end)\n  if ok2 and d and d ~= \"\" then return d end\n  return tostring(obj.tag or \"\")\nend\n\nlocal function q(v)\n  return math.floor(((v or 0) / POS_EPS) + 0.5)\nend\n\nlocal function isFaceDown(obj)\n  if not obj then return false end\n  local ok, v\n  -- PROPERTY FIRST (optimization candidate p1, 2026-09-21). is_face_down\n  -- is the documented member VARIABLE. On the 2026-09-20 probe's Card and\n  -- Deck the callable form failed 27 times out of 27 (\"attempt to call a\n  -- boolean value\") before the property read answered, so every face-down\n  -- check paid one failed protected call for nothing. The property is read\n  -- first now; the two callable forms stay underneath as the fallback for\n  -- any runtime or object shape where it is not a boolean. The test is on\n  -- the TYPE, never on truthiness: `false` is a valid answer and returns\n  -- from the first line. Every branch answers exactly what it did before.\n  ok, v = pcall(function() return obj.is_face_down end)\n  if ok and type(v) == \"boolean\" then return v end\n  ok, v = pcall(function() return obj.is_face_down() end)\n  if ok and type(v) == \"boolean\" then return v end\n  ok, v = pcall(function() return obj.isFaceDown() end)\n  if ok and type(v) == \"boolean\" then return v end\n  return false\nend\n\nlocal function tryGetData(obj)\n  -- ONE READ PER FRAGMENT (Spectator Tool Autodraw build only, 2026-09-07).\n  -- obj.getData() serialises the WHOLE object; this file's own peek comment\n  -- measures 196 ms for the table's 2330-card bag. Seven sites call this, and a\n  -- cold deck could reach three of them inside ONE encodedZoneItemFor, so\n  -- encodedZoneItemFor opens a memo for the object it is about to build and\n  -- closes it the moment the build returns.\n  --\n  -- THE GUID TEST is what makes that safe: the memo answers for exactly one\n  -- object and everything else -- a contained object, the next object in the\n  -- walk -- goes to the engine. Outside a fragment build AUTO.dataMemo is nil\n  -- and this is one field read and one nil test, so nothing else in the tool\n  -- pays for it, and no stale serialisation can survive into a later build.\n  local memo = AUTO.dataMemo\n  if memo ~= nil then\n    local okG, mg = pcall(function() return obj.getGUID() end)\n    if not okG or mg ~= memo.guid then memo = nil end\n    if memo ~= nil and memo.read then return memo.data end\n  end\n  -- THE BIG CONTAINER CAP (2026-09-08), and this is the single choke point it\n  -- needs: seven call sites reach getData and every one of them already handles\n  -- a nil. Above AUTO.BIG_CONTAINER_LIMIT items the serialisation is simply not\n  -- asked for -- 171 ms for this table's 2330-card bag, 141 ms for its 396-card\n  -- deck, every time such a container's peek is invalidated. The verdict is\n  -- recorded on the memo, so a second reader inside the same fragment build does\n  -- not ask the engine for the quantity again.\n  if AUTO.bigContainer(obj, memo) then\n    if memo ~= nil then memo.data = nil; memo.read = true end\n    return nil\n  end\n  local ok, data = pcall(function() return obj.getData() end)\n  if not (ok and data) then data = nil end\n  if memo ~= nil then memo.data = data; memo.read = true end\n  return data\nend\n\n-- =========================\n-- COUNTER HELPERS\n-- =========================\nlocal function tryGetCounterValue(obj)\n  if not obj then return nil end\n  local ok, v = pcall(function() return obj.getValue() end)\n  if ok and type(v) == \"number\" then return v end\n  return nil\nend\n\nlocal function tryCounterTextAnchor(obj)\n  if not obj then return nil end\n  local okB, b = pcall(function() return obj.getBoundsNormalized() end)\n  if not okB or not b or not b.size then return nil end\n  local yTop = (tonumber(b.size.y) or 0) * 0.5 + 0.01\n  local localAnchor = { x = 0, y = yTop, z = 0 }\n\n  local out = { localPos = vec3Round(localAnchor, POS_DECIMALS) }\n  local okW, w = pcall(function() return obj.positionToWorld(localAnchor) end)\n  if okW and type(w) == \"table\" then\n    out.worldPos = vec3Round(w, POS_DECIMALS)\n  end\n  return out\nend\n\n-- =========================\n-- TINT HELPERS\n-- =========================\nlocal function tryGetTint(obj)\n  if not obj then return nil end\n  local tag = safeStr(obj.tag)\n  if tag ~= \"Figurine\" and tag ~= \"Generic\" and tag ~= \"Infinite\" and tag ~= \"Bag\" then return nil end\n  local ok, c = pcall(function() return obj.getColorTint() end)\n  if not ok or type(c) ~= \"table\" then return nil end\n  local r, g, b, a = c.r, c.g, c.b, c.a\n  if type(r) ~= \"number\" or type(g) ~= \"number\" or type(b) ~= \"number\" then return nil end\n  r = roundN(r, 3); g = roundN(g, 3); b = roundN(b, 3)\n  local hasA = type(a) == \"number\"\n  if hasA then a = roundN(a, 3) end\n  -- near-white is the default (no tint): omit to save bytes for every tag\n  -- EXCEPT Generic. Generics keep white so white models still render on the\n  -- site; Figurine/Infinite/Bag drop near-white.\n  if tag ~= \"Generic\" and r > 0.98 and g > 0.98 and b > 0.98 and (not hasA or a > 0.98) then return nil end\n  local out = { r = r, g = g, b = b }\n  if hasA and a < 0.995 then out.a = a end\n  return out\nend\n\n-- =========================\n-- XML UI CAPTURE  (only in the \"Spectator 13 XML\" build)\n-- =========================\n-- Feature folder: xml-ui/ (tts/lua/xml-ui/README.md has the why). The shipping\n-- Spectator 13 has none of this. Nothing here goes near lightObjSig: both calls\n-- run only from the item builders, which are on the round-robin sweep and the\n-- full, never on the per-object-per-tick signature path.\n-- RAW XML, not the parsed tree (Spectator Tool Autodraw build only, 2026-09-08).\n-- The parsed-tree read this replaces (getXmlTable, which the plain build kept\n-- until the plain build was retired on 2026-09-26) builds a tree of Lua tables -- hundreds of them for one Arkham playermat --\n-- and the fragment then JSON-encodes every one of them in pure Lua:\n-- the setup measurement put one playermat at 177 ms and three more at ~70 ms.\n-- obj.UI.getXml() is ONE engine call answering ONE string. It rides the wire as\n-- `xmlRaw` and the site parses it in the browser, where parsing XML is free.\n--\n-- Nothing else about the XML path changes: stampXmlSig still decides when a\n-- fragment has gone stale, and xmlPrev / xmlRev still count the revisions -- off\n-- the same obj.UI.getXml() string, which is why this is a SWAP and not an\n-- addition. The plain build sent the tree as `xml`, and the plain build was\n-- retired on 2026-09-26; the site still accepts both.\n--\n-- ONE READ PER OBJECT (2026-09-08). stampXmlSig makes that call already, on the\n-- metadata refresh, and KEEPS what it read in xmlPrev -- so this used to be the\n-- second engine call for the same string, back to back, on every XML object of\n-- every warm-up. It now answers out of xmlPrev whenever there is an entry, and\n-- goes to the engine only when there is not. What it reads is stored, and\n-- xmlRev is deliberately NOT touched: counting revisions is stampXmlSig's job\n-- and the fragment cache keys on it.\n--\n-- xmlPrev holds the EMPTY string for an object with no XML (stampXmlSig writes\n-- that too since 2026-09-08), which is what stops the objects that carry no XML\n-- -- nearly all of them -- being asked once per encode for ever.\n--\n-- WHAT IT COSTS: the string on the wire is the one stampXmlSig last read, so a\n-- change can ride up to META_REFRESH_SECONDS (12 s) behind. That is already the\n-- cadence at which an XML change is DETECTED, so nothing is noticed later than\n-- before -- what moved is that the payload no longer carries a fresher read than\n-- the detection it belongs to.\n--\n-- The declaration sits HERE, above the function, because Lua binds an upvalue\n-- where the closure is created. It is the same two locals the plain build\n-- declared lower down (the plain build was retired on 2026-09-26), so the\n-- top-level count did not move.\nlocal xmlPrev, xmlRev = {}, {}\nlocal function tryGetXmlRaw(obj)\n  if not obj then return nil end\n  local okG, g = pcall(function() return obj.getGUID() end)\n  local keyed = okG and type(g) == \"string\" and g ~= \"\"\n  if keyed then\n    local cached = xmlPrev[g]\n    if cached ~= nil then\n      if cached == \"\" then return nil end\n      return cached\n    end\n  end\n  local ok, s = pcall(function() return obj.UI.getXml() end)\n  if not ok or type(s) ~= \"string\" then return nil end\n  if keyed then xmlPrev[g] = s end\n  if s == \"\" then return nil end\n  return s\nend\n\n-- Revision stamp for the object's XML, so an XML-ONLY change invalidates its\n-- cached fragment. Without it nothing re-encodes: rrMetaSig digests name, scale\n-- and invisibility, none of which move when a panel opens, so the site keeps\n-- serving the tree as it was first seen.\n--\n-- EXACT, not sampled. The first cut summed every 97th byte to stay cheap in\n-- MoonSharp, and a same-length one-character edit -- a counter digit -- had only\n-- a ~1-in-50 chance of touching a sampled byte. Comparing the whole string\n-- against the last one seen closes that hole for LESS Lua work: `s ~= prev` is\n-- one native .NET string compare (microseconds for 23 KB), no byte() calls at\n-- all. The cost moved to memory -- the previous XML string per object -- which\n-- the dead-GUID sweep in rebuildRoundRobinLists clears (injection below), so it\n-- cannot leak the way the round-robin caches once did.\n-- THE DECLARATION MOVED (Spectator Tool Autodraw build only, 2026-09-08): it\n-- now sits with tryGetXmlRaw above, which answers out of xmlPrev rather than\n-- making a second obj.UI.getXml() call of its own.\nlocal function stampXmlSig(obj, e)\n  if not obj or not e then return end\n  local ok, s = pcall(function() return obj.UI.getXml() end)\n  if not ok or type(s) ~= \"string\" then e.xmlSig = \"\"; return end\n  local okG, g = pcall(function() return obj.getGUID() end)\n  if not okG or type(g) ~= \"string\" or g == \"\" then e.xmlSig = \"\"; return end\n  -- THE EMPTY ANSWER IS STORED TOO (2026-09-08), which is what stops\n  -- tryGetXmlRaw going back to the engine for the objects -- nearly all of them\n  -- -- that carry no XML at all. The change test is still a whole-string\n  -- compare, so an object that GAINS XML later still bumps the revision and\n  -- re-publishes, and one that loses it falls back to an empty xmlSig, which is\n  -- a change as well.\n  if s ~= xmlPrev[g] then\n    xmlPrev[g] = s\n    xmlRev[g] = (xmlRev[g] or 0) + 1\n  end\n  if #s == 0 then e.xmlSig = \"\"; return end\n  e.xmlSig = \"|x\" .. tostring(xmlRev[g])\nend\n\n--\n-- GLOBAL assets, not just the object's own. Inside an object script `UI` is the\n-- GLOBAL UI and `obj.UI` is that object's; a mod can register an asset globally\n-- and reference it from an object that registers nothing. The threat-area damage\n-- counter does exactly that: its XML says font=\"font_arkham-numbers\" while its\n-- own table holds only \"circle\".\n--\n-- SENT ONCE PER PAYLOAD since 2026-09-06, not merged into every object. This\n-- function is now called from exactly one place -- UI_ASSETS.payloadField, next\n-- to the payload builders, once per publish -- and its answer rides the payload\n-- as a single top-level `uiAssets` array. Merging it into each object's list\n-- cost 293 KB of a 580 KB snapshot for ~12 KB of distinct content (measured in\n-- room 429S, 200 objects): 25 objects each carrying the same ~85 entries.\n--\n-- READ FRESH on every call. The first version cached the first non-empty answer\n-- for the life of the object, which was right when the merge made this a\n-- per-object-per-capture read and is wrong now: one read per publish is cheap,\n-- and a mod that registers an asset late has to be noticed.\n--\n-- The state table is the ONE top-level local this feature costs (it replaces\n-- the cache local the merge used, so the build's local count is unchanged;\n-- the named rule topLevelLocalsEquals in tts/build/rules.json pins the count).\n-- `payloadField` hangs off it for the same reason, the way AUTO does.\nlocal UI_ASSETS = { sentSig = nil }  -- signature of the table AS LAST SENT\nlocal function globalUiAssets()\n  local out = {}\n  local ok, a = pcall(function() return UI.getCustomAssets() end)\n  if ok and type(a) == \"table\" then\n    for _, e in ipairs(a) do\n      if type(e) == \"table\" and type(e.name) == \"string\"\n         and type(e.url) == \"string\" and e.url ~= \"\" then\n        out[#out + 1] = { name = e.name, url = e.url }\n      end\n    end\n  end\n  return out\nend\n\n-- WHICH native an object is. `tag` is only the family (\"Block\"); obj.name is\n-- the internal identity (\"BlockSquare\", \"Die_6\", \"Chess_King\") -- the key the\n-- site needs to pick art for built-ins, which have no image URL anywhere.\n-- Custom_* is skipped: those objects already identify themselves by their\n-- image/mesh URLs, and the string would be pure payload.\nlocal function tryGetKind(obj)\n  if not obj then return nil end\n  local ok, n = pcall(function() return obj.name end)\n  if not ok or type(n) ~= \"string\" or n == \"\" then return nil end\n  if n:sub(1, 6) == \"Custom\" then return nil end\n  return n\nend\n\n-- Tint for the tags the shipping tryGetTint declines (Block, Chip, Dice,\n-- Checker, ...): a red block is red ONLY via its colour tint, so dropping it\n-- leaves the site drawing grey. Near-white is omitted as \"no tint\", same rule\n-- as shipping. Only fills item.tint when the shipping capture left it nil, so\n-- Figurine/Generic/Infinite/Bag behaviour is untouched.\n--\n-- GATED TO IMAGELESS NATIVE KINDS by the capture template (2026-09-06). The\n-- shipping allow-list is load-bearing (see tryIsInvisible's comment): Arkham\n-- SCE sets a BLACK ColorDiffuse on its playermats and a grey one on most cards,\n-- which TTS does not visibly apply to those image objects -- but the site\n-- multiplies any tint it receives over the image, so the first table-wide\n-- capture drew every playermat solid black (room 429S). A card, a deck, or\n-- anything carrying an image URL therefore never gets a wide tint; only the\n-- native stand-ins the site draws itself do, and those bake it into their shapes.\nlocal function tryGetWideTint(obj)\n  if not obj then return nil end\n  local ok, c = pcall(function() return obj.getColorTint() end)\n  if not ok or type(c) ~= \"table\" then return nil end\n  local r, g, b, a = c.r, c.g, c.b, c.a\n  if type(r) ~= \"number\" or type(g) ~= \"number\" or type(b) ~= \"number\" then return nil end\n  local hasA = type(a) == \"number\"\n  if r > 0.98 and g > 0.98 and b > 0.98 and (not hasA or a > 0.98) then return nil end\n  local function r3(v) return math.floor(v * 1000 + 0.5) / 1000 end\n  local out = { r = r3(r), g = r3(g), b = r3(b) }\n  if hasA then out.a = r3(a) end\n  return out\nend\n\n-- Piecepack pieces are multi-mesh: MeshIndex picks the physical form (tile /\n-- coin / pawn / die -- the wild shows indexes 0, 1 and 6 in use). The only way\n-- to read it at runtime is getData(), which serialises the WHOLE object, so it\n-- is gated to the one tag that needs it; piecepack pieces are tiny and rare.\nlocal function tryGetMeshIndex(obj, tag)\n  if tag ~= \"Piecepack\" then return nil end\n  local ok, d = pcall(function() return obj.getData() end)\n  if not ok or type(d) ~= \"table\" then return nil end\n  local mi = d.MeshIndex\n  if type(mi) ~= \"number\" or mi < 0 then return nil end\n  return mi\nend\n\n-- The object's OWN custom assets, and ONLY those (2026-09-06). The global table\n-- used to be merged in here, which put the same ~85 entries on all 25 objects\n-- that carry XML -- 293 KB of a 580 KB snapshot. It now rides the payload once,\n-- as the top-level `uiAssets` field (UI_ASSETS.payloadField below), and the site\n-- resolves a name against the object's own list first and the payload's second.\n--\n-- Omitted (nil) when the object registers nothing, which is most of them: an\n-- empty list on the wire would be pure payload.\nlocal function tryGetUiAssets(obj)\n  if not obj then return nil end\n  local out = {}\n  local ok, a = pcall(function() return obj.UI.getCustomAssets() end)\n  if ok and type(a) == \"table\" then\n    for _, e in ipairs(a) do\n      if type(e) == \"table\" and type(e.name) == \"string\"\n         and type(e.url) == \"string\" and e.url ~= \"\" then\n        out[#out + 1] = { name = e.name, url = e.url }\n      end\n    end\n  end\n  if #out == 0 then return nil end\n  return out\nend\n\n-- =========================\n-- 3DText TRACKING  (only in the \"Spectator 13 XML\" build)\n-- =========================\n-- Feature folder: text-3d/ (tts/lua/text-3d/README.md has the why). It sits\n-- in the Manifest right after the XML helpers above, which matters:\n-- stampNameScale (~line 787 of the shipping Lua) calls textSigFor, and Lua\n-- locals are lexically scoped, so textSigFor has to be defined above it. The\n-- other half of this feature -- zoneObjectsPlusTexts -- sits later in the\n-- Manifest, because it in turn calls stampNameScale.\n--\n-- WHY ANY OF THIS. A TTS zone finds its members with a physics collider. The\n-- built-in 3DText object has none, so zone.getObjects() NEVER returns one\n-- (verified in game). The tool simply cannot see table text. So we keep our own\n-- set of 3DText GUIDs and splice the ones physically inside a zone into that\n-- zone's object list; from there they are ordinary members and the existing\n-- snapshot / diff / sweep machinery needs no change at all. The ONE exception\n-- is the round-robin: considerObj keeps texts out of RR_OBJECTS, because a\n-- deleted text's reference raises an uncatchable .NET error when touched.\n--\n-- Verified in game (Text Probe, 2026-09-02) -- these are facts, not guesses:\n--   * onObjectSpawn / onObjectDestroy DO fire in an object script, and the GUID\n--     seen in onObjectSpawn is final. Destroy fires BEFORE the object is gone.\n--   * obj.name == \"3DText\"; obj.getValue() is the text; TextTool.getFontSize()\n--     is a number (default 64) and TextTool.getFontColor() a {r,g,b,a} table\n--     (default 1,1,1,1). getBounds() is unreliable for text -- not used here.\n--   * getObjectFromGUID() answers nil for a destroyed object AND for one inside\n--     a container, which is exactly the \"stop tracking this GUID\" signal.\n--\n-- Nothing here is added to lightObjSig or rrMetaSig: the per-object-per-tick\n-- signature path pays nothing for this feature. Every engine read is pcall'd --\n-- a failed read means \"drop it\", never an error, because two of these run inside\n-- event hooks where a raise would break TTS's own event dispatch.\nlocal TEXT_GUIDS = {}   -- [guid] = true, every 3DText we know exists\nlocal TEXT_LAST = {}    -- [guid] = last text signature string we saw\n\n-- Signature of everything about a text that the site draws. Folded into nameSig\n-- by stampNameScale below, so an edit to the text, its size, its colour or its\n-- scale moves the object signature through the EXISTING rrMetaSig lookup -- no\n-- new work on the hot path, and not one line changed in rrMetaSig itself.\n--\n-- q3 is NOT in scope this early: it is defined ~30 lines BELOW this anchor, so\n-- the rounding is done with string.format here. This is only ever compared\n-- against itself, so the value's newlines and quotes are kept verbatim.\nlocal function textSigFor(obj)\n  if not obj then return \"\" end\n  local okV, v = pcall(function() return obj.getValue() end)\n  if not okV or type(v) ~= \"string\" then return \"\" end\n  local okS, fs = pcall(function() return obj.TextTool.getFontSize() end)\n  if not okS or type(fs) ~= \"number\" then return \"\" end\n  local okC, c = pcall(function() return obj.TextTool.getFontColor() end)\n  if not okC or type(c) ~= \"table\" then return \"\" end\n  local r, g, b = c.r, c.g, c.b\n  if type(r) ~= \"number\" or type(g) ~= \"number\" or type(b) ~= \"number\" then return \"\" end\n  -- Scale is in HERE because texts are off the round-robin (see considerObj), so\n  -- the splice's per-tick textSigFor compare against TEXT_LAST is now the only\n  -- thing that notices a resize -- and it answers by calling stampNameScale,\n  -- which re-stamps nameSig AND scaleSig, so a resize still lands within a tick.\n  local okZ, s = pcall(function() return obj.getScale() end)\n  if not okZ or type(s) ~= \"table\" then return \"\" end\n  if type(s.x) ~= \"number\" or type(s.y) ~= \"number\" or type(s.z) ~= \"number\" then return \"\" end\n  return \"|T\" .. v\n      .. \"|F\" .. string.format(\"%.3f\", fs)\n      .. \"|C\" .. string.format(\"%.3f\", r)\n      .. \",\" .. string.format(\"%.3f\", g)\n      .. \",\" .. string.format(\"%.3f\", b)\n      .. \"|S\" .. string.format(\"%.3f\", s.x)\n      .. \",\" .. string.format(\"%.3f\", s.y)\n      .. \",\" .. string.format(\"%.3f\", s.z)\nend\n\n-- The captured payload for a 3DText. Gated on kind == \"3DText\" at both item\n-- builders, so no other object pays for these three reads.\nlocal function tryGetText(obj)\n  if not obj then return nil end\n  local okV, v = pcall(function() return obj.getValue() end)\n  if not okV or type(v) ~= \"string\" then return nil end\n  local okS, fs = pcall(function() return obj.TextTool.getFontSize() end)\n  if not okS or type(fs) ~= \"number\" then return nil end\n  local okC, c = pcall(function() return obj.TextTool.getFontColor() end)\n  if not okC or type(c) ~= \"table\" then return nil end\n  local r, g, b = c.r, c.g, c.b\n  if type(r) ~= \"number\" or type(g) ~= \"number\" or type(b) ~= \"number\" then return nil end\n  local function r3(x) return math.floor(x * 1000 + 0.5) / 1000 end\n  return { v = v, fs = fs, c = { r = r3(r), g = r3(g), b = r3(b) } }\nend\n\n-- One full getAllObjects() walk, ~0.02 ms an object -- the same walk the zone\n-- poll (findDesiredZonesRaw) already does every ZONE_RESCAN_SECONDS. Called at\n-- room create and from the Rescan button, NEVER on a tick.\n--\n-- Deliberately does NOT clear TEXT_GUIDS: the caller decides. Room create wants\n-- a clean slate; the Rescan button wants to add without dropping texts that are\n-- currently outside every zone (those are still tracked, and must stay tracked,\n-- or moving one INTO a zone would never be noticed).\nlocal function scanTexts()\n  local n = 0\n  local okA, all = pcall(function() return getAllObjects() end)\n  if not okA or type(all) ~= \"table\" then return 0 end\n  for _, o in ipairs(all) do\n    local okN, nm = pcall(function() return o.name end)\n    if okN and nm == \"3DText\" then\n      local okG, g = pcall(function() return o.getGUID() end)\n      if okG and type(g) == \"string\" and g ~= \"\" then\n        TEXT_GUIDS[g] = true\n        n = n + 1\n      end\n    end\n  end\n  return n\nend\n\n-- Called from onObjectSpawn. The GUID seen there is final, so nothing has to be\n-- re-read later.\nlocal function trackTextSpawn(obj)\n  if not obj then return end\n  local okN, nm = pcall(function() return obj.name end)\n  if not okN or nm ~= \"3DText\" then return end\n  local okG, g = pcall(function() return obj.getGUID() end)\n  if okG and type(g) == \"string\" and g ~= \"\" then TEXT_GUIDS[g] = true end\nend\n\n-- Called from onObjectDestroy, which fires BEFORE the object is gone. Drops the\n-- GUID whatever the object was: forgetting a GUID we never tracked costs\n-- nothing, and skipping the name read means a destroy still cleans up even if\n-- the dying object no longer answers it.\nlocal function trackTextDestroy(obj)\n  if not obj then return end\n  local okG, g = pcall(function() return obj.getGUID() end)\n  if not okG or type(g) ~= \"string\" or g == \"\" then return end\n  TEXT_GUIDS[g] = nil\n  TEXT_LAST[g] = nil\nend\n\n-- At or below this alpha TTS draws the object as nothing at all.\nlocal INVISIBLE_ALPHA = 0.02\n\n-- Alpha, and ONLY alpha, read for EVERY tag. TTS renders nothing for an object\n-- whose ColorDiffuse alpha is 0, and Arkham's playermat counters are exactly\n-- that: invisible objects that exist only to carry a button. The site drew their\n-- texture anyway, which is the white ring that showed round the clues \"0\" on the\n-- website and never in game.\n--\n-- Deliberately NOT done by lifting tryGetTint's tag allow-list, which looks like\n-- the obvious one-line fix and is a trap: 2303 of the 2544 objects in Arkham SCE\n-- 4.7.0 carry a non-white ColorDiffuse, 1956 of them plain Cards. Un-gating would\n-- attach a tint to every card on the table and colour the lot. That allow-list is\n-- load-bearing; only the alpha needs to escape it.\n--\n-- Returns true only for objects TTS draws as nothing, so the payload grows for\n-- those few and for nobody else.\nlocal function tryIsInvisible(obj)\n  if not obj then return false end\n  local ok, c = pcall(function() return obj.getColorTint() end)\n  if not ok or type(c) ~= \"table\" then return false end\n  local a = c.a\n  if type(a) ~= \"number\" then return false end\n  return a <= INVISIBLE_ALPHA\nend\n\n-- =========================\n-- DIFF SIGNATURE HELPERS (NEW)\n-- =========================\nlocal function q3(v) return q(v) end\n\nlocal function counterSig(obj)\n  local v = tryGetCounterValue(obj)\n  if v == nil then return \"\" end\n  return \"C\" .. tostring(v)\nend\n\nlocal function tintSig(obj)\n  local t = tryGetTint(obj)\n  if t == nil then return \"\" end\n  return \"T\" .. q3(t.r) .. \",\" .. q3(t.g) .. \",\" .. q3(t.b)\nend\n\n-- Reads BTN_CACHE ONLY (no getButtons / TTS API). The RR tick (rrStepButtons)\n-- re-reads getButtons into BTN_CACHE on a budget each poll; this function just\n-- digests the cached labels so a label-only change (e.g. Terraforming Mars\n-- counters updated via editButton) alters the object sig. The same cache entry\n-- now also carries pre-digested nameSig/scaleSig (stamped by refreshButtonsEntry\n-- on each RR visit); rrMetaSig digests those as a pure lookup.\nlocal function buttonsSig(guid)\n  local e = BTN_CACHE[guid]\n  if not e or type(e.buttons) ~= \"table\" or #e.buttons == 0 then return \"\" end\n  local parts = {}\n  for i, b in ipairs(e.buttons) do\n    local lbl = tostring(b.label or \"\")\n    if #lbl > 24 then lbl = string.sub(lbl, 1, 24) end\n    parts[#parts+1] = lbl\n  end\n  -- Labels are digested live (above) because a counter's text changes constantly\n  -- and must be caught on the tick it changes. The GEOMETRY half is a ready-made\n  -- string stamped when the buttons were last read -- see stampButtonGeom for why\n  -- it is not rebuilt here. Without it, a button whose size or scale changed but\n  -- whose text did not produced an identical sig, so the stale fragment was\n  -- re-sent and the site kept the old geometry until the fragment TTL expired\n  -- (~22-34 min).\n  return \"B\" .. tostring(#e.buttons) .. \":\" .. table.concat(parts, \",\") .. (e.btnGeomSig or \"\")\nend\n\n-- Pure BTN_CACHE lookup of the name/scale sigs stamped by refreshButtonsEntry\n-- during the RR sweep. Digested here so a rename or scale edit alters the object\n-- sig without any live engine read on the hot signature path.\nlocal function rrMetaSig(guid)\n  local e = BTN_CACHE[guid]\n  if not e then return \"\" end\n  return (e.nameSig or \"\") .. (e.scaleSig or \"\") .. (e.invisSig or \"\") .. (e.xmlSig or \"\") .. (e.ioSig or \"\") .. (e.descSig or \"\")\nend\n\n-- Container tags whose live quantity (contained-object count) must ride the\n-- signatures. Shared by lightObjSig, AUTO.spreadGate, and encodedZoneItemFor\n-- (the fragment cache's peek-invalidation).\nlocal function isContainerTag(tag)\n  return tag == \"Deck\" or tag == \"Bag\" or tag == \"Infinite\" or tag == \"InfiniteBag\"\nend\n\n-- getQuantity() is a cheap engine property (contained-object count; -1 for\n-- non-containers). pcall-guarded so an odd object can never break a signature.\nlocal function tryGetQuantity(obj)\n  if not obj then return -1 end\n  local ok, qty = pcall(function() return obj.getQuantity() end)\n  if ok and type(qty) == \"number\" then return qty end\n  return -1\nend\n\nlocal function lightObjSig(obj, handIdx, inHandIndex)\n  if not obj then return \"\" end\n  local g = safeStr(obj.getGUID())\n  local tag = safeStr(obj.tag)\n\n  local p = obj.getPosition()\n  local r = obj.getRotation()\n\n  local fd = \"\"\n  if tag == \"Card\" or tag == \"Deck\" then\n    fd = isFaceDown(obj) and \"D1\" or \"D0\"\n  end\n\n  -- Hand component encodes both the zone and the within-zone index so a card\n  -- moving BETWEEN two hand zones of the same player produces a changed sig.\n  local hs = \"\"\n  if inHandIndex ~= nil then hs = \"H\" .. tostring(handIdx or 1) .. \".\" .. tostring(inHandIndex) end\n\n  -- Container contents are otherwise invisible to the sig: a deck drawn from\n  -- without moving changes nothing above. Tag-guarded so only containers pay a\n  -- getQuantity() here.\n  local qs = \"\"\n  if isContainerTag(tag) then qs = \"Q\" .. tostring(tryGetQuantity(obj)) end\n\n  return table.concat({\n    g, tag, hs,\n    \"P\" .. q3(p.x) .. \",\" .. q3(p.y) .. \",\" .. q3(p.z),\n    \"R\" .. q3(r.x) .. \",\" .. q3(r.y) .. \",\" .. q3(r.z),\n    fd,\n    counterSig(obj),\n    tintSig(obj),\n    buttonsSig(g),\n    rrMetaSig(g),\n    qs,\n    -- Shuffle epoch: a randomize reorders container contents in place, invisible\n    -- to every field above; this bumped counter is the only tell (pure lookup).\n    (SHUFFLE_EPOCH[g] and (\"E\" .. SHUFFLE_EPOCH[g]) or \"\"),\n  }, \"|\")\nend\n\nlocal function zoneMetaSig(z)\n  local zg = safeStr(z.getGUID())\n  local name = safeStr(z.getName())\n  local p = z.getPosition()\n  local r = z.getRotation()\n  -- Scale belongs in the signature. Without it, resizing a zone in game changed\n  -- nothing the diff could detect, so a still table emitted no zoneDiff at all and\n  -- the new size only reached the site on the next periodic full (up to 600s later).\n  local okS, sc = pcall(function() return z.getScale() end)\n  local sSig = (okS and sc) and (\"S\" .. q3(sc.x) .. \",\" .. q3(sc.y) .. \",\" .. q3(sc.z)) or \"S?\"\n  return table.concat({\n    zg, name,\n    \"P\" .. q3(p.x) .. \",\" .. q3(p.y) .. \",\" .. q3(p.z),\n    \"R\" .. q3(r.x) .. \",\" .. q3(r.y) .. \",\" .. q3(r.z),\n    sSig,\n  }, \"|\")\nend\n\n-- =========================\n-- OBJECT \"BUTTON SPACE\" MAPPING\n-- =========================\nlocal function vecSub(a,b) return { x=(a.x or 0)-(b.x or 0), y=(a.y or 0)-(b.y or 0), z=(a.z or 0)-(b.z or 0) } end\nlocal function vecMag(v) return math.sqrt((v.x or 0)^2 + (v.y or 0)^2 + (v.z or 0)^2) end\n\nlocal function computeButtonSpaceAspect(obj)\n  local out = { dx = 0, dy = 0, dz = 0, ratio = 0 }\n  if not obj then return out end\n\n  local ok0, p0 = pcall(function() return obj.positionToWorld({0,0,0}) end)\n  if not ok0 or type(p0) ~= \"table\" then return out end\n\n  local okX, px = pcall(function() return obj.positionToWorld({1,0,0}) end)\n  local okY, py = pcall(function() return obj.positionToWorld({0,1,0}) end)\n  local okZ, pz = pcall(function() return obj.positionToWorld({0,0,1}) end)\n  if (not okX) or (type(px) ~= \"table\") or (not okY) or (type(py) ~= \"table\") or (not okZ) or (type(pz) ~= \"table\") then\n    return out\n  end\n\n  local dx = vecMag(vecSub(px, p0))\n  local dy = vecMag(vecSub(py, p0))\n  local dz = vecMag(vecSub(pz, p0))\n\n  out.dx = roundN(dx or 0, POS_DECIMALS)\n  out.dy = roundN(dy or 0, POS_DECIMALS)\n  out.dz = roundN(dz or 0, POS_DECIMALS)\n\n  if dx and dx > 0 and dz and dz > 0 then out.ratio = roundN(dz / dx, 4) else out.ratio = 0 end\n  return out\nend\n\n-- =========================\n-- BUTTON EXPORT HELPERS\n-- =========================\nlocal function tryParseNumberLabel(s)\n  if s == nil then return nil end\n  s = tostring(s):gsub(\"^%s+\", \"\"):gsub(\"%s+$\", \"\")\n  local n = tonumber(s)\n  if n == nil then return nil end\n  return n\nend\n\nlocal function rgbaOrNil(c)\n  if type(c) ~= \"table\" then return nil end\n  local r = tonumber(c[1] or c.r)\n  local g = tonumber(c[2] or c.g)\n  local b = tonumber(c[3] or c.b)\n  local a = tonumber(c[4] or c.a)\n  if r == nil or g == nil or b == nil then return nil end\n  if a == nil then a = 1 end\n  return { r=r, g=g, b=b, a=a }\nend\n\nlocal function vec3FromAny(p)\n  if type(p) ~= \"table\" then return {x=0,y=0,z=0} end\n  return { x = tonumber(p.x or p[1]) or 0, y = tonumber(p.y or p[2]) or 0, z = tonumber(p.z or p[3]) or 0 }\nend\n\nlocal function extractButtonsRaw(obj)\n  local t0 = os.clock()\n  local ok, btns = pcall(function() return obj.getButtons() end)\n  local dt = os.clock() - t0\n\n  if DEBUG_ENABLED then\n    PROF.btnRawCalls = PROF.btnRawCalls + 1\n    -- LAZY LABEL (optimization candidate p0, 2026-09-21; Debug only).\n    -- The label costs a getGUID and a getName/getDescription, and\n    -- profSlowPush keeps it only when the read crossed DEBUG_SLOW_MS --\n    -- so it is built only then. Same test profSlowPush makes, made first.\n    if ms(dt) >= DEBUG_SLOW_MS then\n      profSlowPush(\"buttons\", \"getButtons guid=\" .. safeStr(obj.getGUID()) .. \" name=\" .. safeName(obj), dt)\n    end\n  end\n\n  if not ok or type(btns) ~= \"table\" then return nil end\n  if #btns == 0 then return nil end\n\n  local out = {}\n  for i, b in ipairs(btns) do\n    if type(b) == \"table\" then\n      local lbl = b.label\n      local parsed = tryParseNumberLabel(lbl)\n      table.insert(out, {\n        index = i - 1,\n        label = (lbl ~= nil) and tostring(lbl) or \"\",\n        value = parsed,\n\n        position  = vec3Round(vec3FromAny(b.position), POS_DECIMALS),\n        rotation  = rotRound(vec3FromAny(b.rotation), ROT_DECIMALS),\n        scale     = vec3Round(vec3FromAny(b.scale), POS_DECIMALS),\n\n        width     = tonumber(b.width) or 0,\n        height    = tonumber(b.height) or 0,\n        font_size = tonumber(b.font_size) or 0,\n\n        color      = rgbaOrNil(b.color),\n        font_color = rgbaOrNil(b.font_color),\n      })\n    end\n  end\n\n  if DEBUG_ENABLED then PROF.btnRawButtonsTotal = PROF.btnRawButtonsTotal + (#out or 0) end\n  if #out == 0 then return nil end\n  return out\nend\n\n-- Digest an object's name and scale into the cheap sig strings that both the RR\n-- sweep and the fulls consume. Shared by refreshButtonsEntry (throttled) and\n-- ensureMetaSeeded (one-time full seed) so those two paths can never drift on\n-- how a name/scale is encoded. (q3 / safeStr are in scope here.)\nlocal function stampNameScale(obj, e)\n  local okN, nm = pcall(function() return obj.getName() end)\n  local name = okN and safeStr(nm) or \"\"\n  e.nameSig = (name ~= \"\" and (\"N\" .. name) or \"\")\n  -- 3DText: fold the text/size/colour signature into nameSig (Spectator 13 XML\n  -- build only). obj.name is read per stamp, not per tick -- this function is on\n  -- the throttled metadata path, never on lightObjSig's.\n  local okK, kn = pcall(function() return obj.name end)\n  if okK and kn == \"3DText\" then e.nameSig = e.nameSig .. textSigFor(obj) end\n  local okS, s = pcall(function() return obj.getScale() end)\n  if okS and type(s) == \"table\" then\n    e.scaleSig = \"S\" .. q3(s.x) .. \",\" .. q3(s.y) .. \",\" .. q3(s.z)\n  else\n    e.scaleSig = \"\"\n  end\nend\n\n-- Stamped rather than read live for the usual reason: tryIsInvisible costs a\n-- getColorTint(), and lightObjSig runs for EVERY object every tick, where nothing\n-- new is allowed to land. rrMetaSig then digests this as a pure lookup. Rides the\n-- same throttle as stampNameScale, so an object turning invisible mid-game\n-- surfaces within META_REFRESH_SECONDS instead of instantly -- the same trade\n-- already made for renames, and alpha changes about as often.\nlocal function stampInvis(obj, e)\n  e.invisSig = tryIsInvisible(obj) and \"V1\" or \"\"\nend\n\n-- Pre-digest the GEOMETRY of a freshly-read button list: font size, box size, and\n-- the button's own scale/position/rotation. Stamped HERE, on the sweep that just\n-- paid for the getButtons() call, so buttonsSig can paste one ready-made string\n-- instead of rebuilding this for every object twice a second. lightObjSig runs for\n-- EVERY object each tick while this runs only for the RR_BTN_BUDGET objects that\n-- were actually re-read, so the work lands on the cheaper of the two loops -- the\n-- same reason stampNameScale exists.\n--\n-- Labels are deliberately excluded: buttonsSig still digests those live, because a\n-- label changes constantly and has to be noticed on the tick it changes. Geometry\n-- changes essentially never happen, so surfacing them one sweep late (~1s on a\n-- small table) is the trade stampNameScale already makes for name/scale.\n--\n-- Everything goes through q3. Rounding is not cosmetic here: an unrounded float\n-- jittering in its last decimal would flip this sig every tick and re-encode every\n-- counter forever, which would be a real latency regression.\nlocal function stampButtonGeom(e)\n  local btns = e.buttons\n  if type(btns) ~= \"table\" or #btns == 0 then e.btnGeomSig = \"\" return end\n  local parts = {}\n  for i = 1, #btns do\n    local b = btns[i]\n    local s, p, r = b.scale, b.position, b.rotation\n    parts[#parts+1] = table.concat({\n      q3(b.font_size), q3(b.width), q3(b.height),\n      s and (q3(s.x) .. \",\" .. q3(s.z)) or \"-\",\n      p and (q3(p.x) .. \",\" .. q3(p.y) .. \",\" .. q3(p.z)) or \"-\",\n      r and (q3(r.x) .. \",\" .. q3(r.y) .. \",\" .. q3(r.z)) or \"-\",\n    }, \".\")\n  end\n  e.btnGeomSig = \"G\" .. table.concat(parts, \";\")\nend\n\n-- Read-and-store body shared by extractButtonsCached (TTL-gated) and\n-- rrStepButtons (unconditional RR refresh). Calls the real getButtons via\n-- extractButtonsRaw and writes buttons/hasButtons/nextAt into cache entry `e`,\n-- so the two callers can never drift on how a refresh is recorded.\nlocal function refreshButtonsEntry(obj, e, now)\n  local btns = extractButtonsRaw(obj)\n  if btns then\n    e.buttons = btns\n    e.hasButtons = true\n    e.nextAt = now + BTN_REFRESH_ACTIVE_SECONDS\n  else\n    e.buttons = nil\n    e.hasButtons = false\n    e.nextAt = now + BTN_REFRESH_INACTIVE_SECONDS\n  end\n  -- Must follow the e.buttons writes above: this is the ONLY place they happen,\n  -- so stamping here covers every path that can populate the cache.\n  stampButtonGeom(e)\n  -- INPUTS AND DECALS (Spectator Tool Autodraw build only, 2026-09-07), read on\n  -- the SAME visit that just paid for getButtons and nowhere else -- the rule the\n  -- buttons already keep. AUTO.stampIoSig digests both into e.ioSig, which\n  -- rrMetaSig folds in as a pure lookup, so a typed value or a moved decal moves\n  -- the object's signature without adding one engine call to the per-object\n  -- per-tick path. Cost: two reads of getButtons' order (~0.25 ms each) per\n  -- visit -- the experiment the user asked for on 2026-09-07.\n  e.inputs = AUTO.readInputs(obj)\n  e.decals = AUTO.readDecals(obj)\n  AUTO.stampIoSig(e)\n  -- THE NOTECARD'S BODY (Spectator Tool Autodraw build only, 2026-09-14), on the\n  -- SAME visit and by the same rule as the two reads above. One pcall'd property\n  -- read decides the kind and getDescription() is made for a Notecard and for\n  -- nothing else; AUTO.stampDesc digests the text into e.descSig, which rrMetaSig\n  -- folds in as a pure lookup -- so an EDIT moves the change gate without adding\n  -- an engine call to the per-object per-tick path.\n  AUTO.stampDesc(obj, e)\n  -- Buttons are refreshed on every RR visit (above). Name/scale change far more\n  -- rarely, so re-read them only every META_REFRESH_SECONDS via the shared\n  -- stampNameScale helper; rrMetaSig then digests the stamped nameSig/scaleSig as\n  -- PURE LOOKUPS (see rrMetaSig). A rename/rescale surfaces within one throttle\n  -- window rather than instantly -- an acceptable trade for the cheaper sweep.\n  if now >= (e.metaNextAt or 0) then\n    stampNameScale(obj, e)\n    stampInvis(obj, e)\n    stampXmlSig(obj, e)\n    e.metaNextAt = now + META_REFRESH_SECONDS\n  end\n  e.populated = true  -- a real read happened (even if the object has no buttons)\n  return e.buttons\nend\n\n-- Seeds name/scale so a full's per-object signatures are complete the moment it\n-- is built, eliminating the ~6s post-full drift (RR stamping objects one sweep\n-- at a time) that would otherwise churn the cheap gate right after every full.\n-- Ensures a BTN_CACHE entry exists (also touched by refreshButtonsEntry later)\n-- and stamps it exactly once -- nil nameSig means \"never stamped\"; \"\" is itself\n-- a real stamped value for a nameless object, so it must NOT re-trigger.\nlocal function ensureMetaSeeded(obj, guid, now)\n  local e = BTN_CACHE[guid]\n  if not e then e = { buttons = nil, hasButtons = false, nextAt = 0 }; BTN_CACHE[guid] = e end\n  if e.nameSig == nil then\n    stampNameScale(obj, e)\n    stampInvis(obj, e)\n    stampXmlSig(obj, e)\n    e.metaNextAt = now + META_REFRESH_SECONDS\n  end\n  return e\nend\n\nlocal function extractButtonsCached(obj, staleOk)\n  if not obj then return nil end\n  local guid = safeStr(obj.getGUID())\n  if guid == \"\" then\n    if DEBUG_ENABLED then PROF.btnMisses = PROF.btnMisses + 1 end\n    return extractButtonsRaw(obj)\n  end\n\n  local now = os.clock()\n  local e = BTN_CACHE[guid]\n  if not e then\n    e = { buttons = nil, hasButtons = false, nextAt = 0 }\n    BTN_CACHE[guid] = e\n  end\n\n  -- staleOk (FULL path): once this entry has been read at least once, serve it\n  -- regardless of TTL and do NOT touch nextAt. The RR refresher keeps it\n  -- <=2-10s fresh and diffs deliver exact button changes the instant they\n  -- happen, so a periodic full may ride slightly-stale buttons. A never-read\n  -- entry (populated=false) still falls through to a real read so the first\n  -- full after room creation carries real data.\n  if staleOk and e.populated then\n    if DEBUG_ENABLED then PROF.btnHits = PROF.btnHits + 1 end\n    return e.buttons\n  end\n\n  if (not FORCE_BUTTON_REFRESH) and now < (e.nextAt or 0) then\n    if DEBUG_ENABLED then PROF.btnHits = PROF.btnHits + 1 end\n    return e.buttons\n  end\n\n  if DEBUG_ENABLED then PROF.btnMisses = PROF.btnMisses + 1 end\n  return refreshButtonsEntry(obj, e, now)\nend\n\n-- =========================\n-- IMAGE HELPERS (CustomDeck)\n-- =========================\nlocal function getCardIdRobust(obj)\n  local ok, cid\n  ok, cid = pcall(function() return obj.getCardID() end)\n  if ok and type(cid) == \"number\" then return cid end\n  ok, cid = pcall(function() return obj.getCardId() end)\n  if ok and type(cid) == \"number\" then return cid end\n  return nil\nend\n\nlocal function getCardIdFromData(obj)\n  local data = tryGetData(obj)\n  if not data then return nil end\n  if type(data.CardID) == \"number\" then return data.CardID end\n  if type(data.CardId) == \"number\" then return data.CardId end\n  if type(data.cardID) == \"number\" then return data.cardID end\n  if type(data.cardId) == \"number\" then return data.cardId end\n  return nil\nend\n\nlocal function tryCustomDeckImagesByCardIDFromData(data, cardID)\n  if not data or not data.CustomDeck or type(cardID) ~= \"number\" then return nil end\n\n  local deckId = math.floor(cardID / 100)\n  local idx = cardID % 100\n  if idx < 0 then idx = 0 end\n\n  local deck = data.CustomDeck[tostring(deckId)] or data.CustomDeck[deckId]\n  if not deck then return nil end\n\n  local front = deck.FaceURL or deck.face or deck.FaceUrl\n  local back  = deck.BackURL or deck.back or deck.BackUrl\n  local w = tonumber(deck.NumWidth  or deck.numWidth  or deck.Width)\n  local h = tonumber(deck.NumHeight or deck.numHeight or deck.Height)\n\n  if (not front or front == \"\") and (not back or back == \"\") then return nil end\n  if not w or not h or w <= 0 or h <= 0 then return nil end\n\n  front = normalizeHttps(front or \"\")\n  back  = normalizeHttps(back  or \"\")\n\n  local ub = (deck.UniqueBack == true)\n  return front, back, w, h, idx, ub\nend\n\nlocal function tryCustomDeckImagesByCardIDFromCustomDeck(customDeck, cardID)\n  if type(customDeck) ~= \"table\" or type(cardID) ~= \"number\" then return nil end\n\n  local deckId = math.floor(cardID / 100)\n  local idx = cardID % 100\n  if idx < 0 then idx = 0 end\n\n  local deck = customDeck[tostring(deckId)] or customDeck[deckId]\n  if not deck then return nil end\n\n  local front = deck.FaceURL or deck.face or deck.FaceUrl\n  local back  = deck.BackURL or deck.back or deck.BackUrl\n  local w = tonumber(deck.NumWidth  or deck.numWidth  or deck.Width)\n  local h = tonumber(deck.NumHeight or deck.numHeight or deck.Height)\n\n  if (not front or front == \"\") and (not back or back == \"\") then return nil end\n  if not w or not h or w <= 0 or h <= 0 then return nil end\n\n  front = normalizeHttps(front or \"\")\n  back  = normalizeHttps(back  or \"\")\n\n  local ub = (deck.UniqueBack == true)\n  return front, back, w, h, idx, ub\nend\n\nlocal function tryCustomDeckImagesForCard(obj)\n  if not obj or obj.tag ~= \"Card\" then return nil end\n  local cid = getCardIdRobust(obj)\n  if not cid then cid = getCardIdFromData(obj) end\n  if not cid then return nil end\n  local data = tryGetData(obj)\n  if not data or not data.CustomDeck then return nil end\n  return tryCustomDeckImagesByCardIDFromData(data, cid)\nend\n\nlocal function trySingleImage(obj)\n  local ok, custom = pcall(function() return obj.getCustomObject() end)\n  if ok and custom then\n    if custom.image and custom.image ~= \"\" then return normalizeHttps(custom.image) end\n    if custom.diffuse and custom.diffuse ~= \"\" then return normalizeHttps(custom.diffuse) end\n  end\n  return nil\nend\n\n-- Custom_Model mesh URL for the client-side top-down mesh renderer.\nlocal function tryGetMeshUrl(obj)\n  local ok, c = pcall(function() return obj.getCustomObject() end)\n  if ok and type(c) == \"table\" then\n    local m = c.mesh or c.MeshURL or c.mesh_url\n    if type(m) == \"string\" and m ~= \"\" then return normalizeHttps(m) end\n  end\n  return nil\nend\n\nlocal function tryFrontBackImages(obj)\n  local ok, c = pcall(function() return obj.getCustomObject() end)\n  if not ok or type(c) ~= \"table\" then return nil end\n\n  local front = c.image or c.diffuse or c.ImageURL or c.DiffuseURL\n  local back  = c.image_secondary or c.ImageSecondaryURL or c.secondary or c.SecondaryURL\n\n  front = normalizeHttps(front or \"\")\n  back  = normalizeHttps(back  or \"\")\n\n  if front == \"\" and back == \"\" then return nil end\n  return (front ~= \"\" and front or nil), (back ~= \"\" and back or nil)\nend\n\nlocal function safeGetContainerObjects(obj)\n  if not obj then return nil end\n  local t = tostring(obj.tag or \"\")\n  if t ~= \"Deck\" and t ~= \"Bag\" and t ~= \"InfiniteBag\" then return nil end\n  -- ONE getObjects PER FRAGMENT BUILD (Spectator Tool Autodraw build only,\n  -- 2026-09-08), memoised exactly as obj.getData() has been since 2026-09-07.\n  -- A cold deck asks THREE times inside one build -- deckPeekHybrid,\n  -- deckPeekFromGetObjects and deckPreviewSprite -- and each ask builds a Lua\n  -- table with one entry per contained card. The GUID test is what makes the\n  -- memo safe: a contained object, or the next object in the walk, falls\n  -- straight through to the engine.\n  local memo = AUTO.dataMemo\n  if memo ~= nil then\n    local okG, mg = pcall(function() return obj.getGUID() end)\n    if not okG or mg ~= memo.guid then memo = nil end\n    if memo ~= nil and memo.objsRead then return memo.objs end\n  end\n  -- TOO BIG TO LIST AT ALL (2026-09-08). Above AUTO.HUGE_CONTAINER_LIMIT the\n  -- read is not made: containerPeekRaw answers with the count alone, and\n  -- deckPreviewSprite -- the other caller -- falls back to the object's single\n  -- image. The nil is recorded on the memo, so a second caller inside this build\n  -- does not ask the quantity again either.\n  if AUTO.hugeContainer(obj, memo) then\n    if memo ~= nil then memo.objs = nil; memo.objsRead = true end\n    return nil\n  end\n  local ok, list = pcall(function() return obj.getObjects() end)\n  if not (ok and type(list) == \"table\") then list = nil end\n  if memo ~= nil then memo.objs = list; memo.objsRead = true end\n  return list\nend\n\nlocal function deckPreviewSprite(obj)\n  if not obj or obj.tag ~= \"Deck\" then return nil end\n  local list = safeGetContainerObjects(obj)\n  if not list or #list == 0 then return nil end\n\n  local facedown = isFaceDown(obj)\n  local chosen = facedown and list[1] or list[#list]\n\n  local cardID = chosen and (chosen.cardID or chosen.CardID or chosen.cardId or chosen.CardId)\n  if type(cardID) ~= \"number\" then\n    local data = tryGetData(obj)\n    if data and type(data.DeckIDs) == \"table\" and #data.DeckIDs > 0 then\n      cardID = tonumber(facedown and data.DeckIDs[1] or data.DeckIDs[#data.DeckIDs])\n    end\n  end\n  if type(cardID) ~= \"number\" then return nil end\n\n  local data = tryGetData(obj)\n  if not data or not data.CustomDeck then return nil end\n\n  local front, back, w, h, idx, ub = tryCustomDeckImagesByCardIDFromData(data, cardID)\n  if not front and not back then return nil end\n\n  if facedown then\n    if not back or back == \"\" then return nil end\n    if ub then\n      -- UniqueBack: back is a sprite sheet with the same grid/index as the front.\n      return { url=back, w=w, h=h, i=idx, isBack=true, backIsSheet=true, cardID=cardID }\n    end\n    -- Shared back: a single image, no sprite grid.\n    return { url=back, isBack=true, cardID=cardID }\n  else\n    if not front or front == \"\" then return nil end\n    return { url=front, w=w, h=h, i=idx, isBack=false, cardID=cardID }\n  end\nend\n\n-- =========================\n-- CONTAINED OBJECT IMAGE HELPERS\n-- =========================\nlocal function pickFirstUrl(...)\n  for i = 1, select(\"#\", ...) do\n    local u = select(i, ...)\n    if u and type(u) == \"string\" and u ~= \"\" then return normalizeHttps(u) end\n  end\n  return nil\nend\n\nlocal function tryContainedFrontBackImage(co)\n  if type(co) ~= \"table\" then return nil end\n\n  if type(co.CustomObject) == \"table\" then\n    local front = pickFirstUrl(co.CustomObject.image, co.CustomObject.diffuse, co.CustomObject.ImageURL, co.CustomObject.ImageUrl)\n    local back  = pickFirstUrl(co.CustomObject.image_secondary, co.CustomObject.ImageSecondaryURL, co.CustomObject.ImageSecondaryUrl, co.CustomObject.secondary)\n    if front or back then return front, back end\n  end\n\n  if type(co.CustomImage) == \"table\" then\n    local front = pickFirstUrl(co.CustomImage.ImageURL, co.CustomImage.ImageUrl, co.CustomImage.DiffuseURL, co.CustomImage.DiffuseUrl)\n    local back  = pickFirstUrl(co.CustomImage.ImageSecondaryURL, co.CustomImage.ImageSecondaryUrl)\n    if front or back then return front, back end\n  end\n\n  if type(co.CustomToken) == \"table\" then\n    local front = pickFirstUrl(co.CustomToken.ImageURL, co.CustomToken.ImageUrl, co.CustomToken.DiffuseURL, co.CustomToken.DiffuseUrl)\n    local back  = pickFirstUrl(co.CustomToken.ImageSecondaryURL, co.CustomToken.ImageSecondaryUrl)\n    if front or back then return front, back end\n  end\n\n  if type(co.CustomMesh) == \"table\" then\n    local front = pickFirstUrl(co.CustomMesh.DiffuseURL, co.CustomMesh.DiffuseUrl, co.CustomMesh.TextureURL, co.CustomMesh.TextureUrl, co.CustomMesh.ImageURL, co.CustomMesh.ImageUrl)\n    if front then return front, nil end\n  end\n\n  if type(co.CustomAssetbundle) == \"table\" then\n    local front = pickFirstUrl(co.CustomAssetbundle.AssetbundleURL, co.CustomAssetbundle.AssetbundleUrl, co.CustomAssetbundle.URL, co.CustomAssetbundle.Url)\n    if front then return front, nil end\n  end\n\n  local front = pickFirstUrl(co.FaceURL, co.FaceUrl, co.ImageURL, co.ImageUrl, co.DiffuseURL, co.DiffuseUrl)\n  if front then return front, nil end\n\n  return nil\nend\n\nlocal function tryContainedSingleImage(co)\n  local f, b = tryContainedFrontBackImage(co)\n  return f or b\nend\n\n-- For an imageless Bag/Infinite: peek the FIRST ContainedObject in the bag's\n-- own data and reuse the contained-object image extraction to pull a front\n-- image URL. Cheap: the data call is pcall-wrapped in tryGetData and callers\n-- only invoke this when the bag has no image of its own.\nlocal function tryBagFirstContainedFront(obj)\n  local data = tryGetData(obj)\n  if not data or type(data.ContainedObjects) ~= \"table\" then return nil end\n  local co = data.ContainedObjects[1]\n  if type(co) ~= \"table\" then return nil end\n  local front = tryContainedFrontBackImage(co)\n  return front\nend\n\n-- =========================\n-- CONTAINER PEEK (RAW + CACHED WRAPPER)\n-- =========================\nlocal function enrichPeekItemFromContainedObject(co)\n  local out = {\n    name  = safeStr(co.Nickname or co.nickname or co.Name or co.name or \"\"),\n    guid  = safeStr(co.GUID or co.guid or \"\"),\n    desc  = safeStr(co.Description or co.description or \"\"),\n    tag   = safeStr(co.Tag or co.tag or \"\"),\n    cardID = co.CardID or co.cardID or co.CardId or co.cardId,\n  }\n\n  if type(out.cardID) == \"number\" and type(co.CustomDeck) == \"table\" then\n    local front, back, w, h, idx = tryCustomDeckImagesByCardIDFromCustomDeck(co.CustomDeck, out.cardID)\n    if front or back then\n      out.front = (front and front ~= \"\") and front or nil\n      out.back  = (back  and back  ~= \"\") and back  or nil\n      out.w = w; out.h = h; out.i = idx\n    end\n  end\n\n  if not out.front and not out.img and not out.img_front then\n    local f, b = tryContainedFrontBackImage(co)\n    if f then out.img_front = f end\n    if b then out.img_back  = b end\n    if (not out.img_front) and (not out.img_back) then\n      local img = tryContainedSingleImage(co)\n      if img then out.img = img end\n    end\n  end\n\n  return out\nend\n\nlocal function deckPeekHybrid(deckObj, data)\n  local list = safeGetContainerObjects(deckObj)\n  if not list or #list == 0 or not data then return nil end\n\n  local ids = (type(data.DeckIDs) == \"table\") and data.DeckIDs or nil\n  local hasCustom = (data.CustomDeck ~= nil)\n\n  local items = {}\n  local total = #list\n\n  -- DECK TAIL (Spectator Tool Autodraw build only, 2026-09-07). BOTH ENDS of a\n  -- big deck, out of the list this peek already holds -- not one extra engine\n  -- call. `items` is still the first MAX_CONTAINER_PEEK cards in physical order;\n  -- `tail` is the LAST MAX_CONTAINER_PEEK, and exists only when the deck holds\n  -- more than that many. Every entry carries `idx`, its 1-based physical\n  -- position with the top at 1, so the site can merge the two ends without\n  -- drawing a card twice where they overlap (81..160 cards) and put an ellipsis\n  -- between them where they do not. `total` is untouched: the true count.\n  local headN, tailFrom, tail = AUTO.deckEnds(total)\n\n  for idx = 1, total do\n    local inHead = (idx <= headN)\n    local inTail = (tailFrom ~= nil and idx >= tailFrom)\n    if inHead or inTail then\n      local it = list[idx]\n\n      local name = safeStr(it.nickname or it.name or it.Name or \"\")\n      local guid = safeStr(it.guid or it.GUID or \"\")\n      local desc = safeStr(it.description or it.Description or \"\")\n\n      local cardID = it.cardID or it.CardID or it.cardId or it.CardId\n      if type(cardID) ~= \"number\" and ids and ids[idx] ~= nil then\n        cardID = tonumber(ids[idx])\n      end\n\n      local entry = { name=name, guid=guid, desc=desc, tag=\"Card\", cardID=cardID }\n\n      if hasCustom and type(cardID) == \"number\" then\n        local front, back, w, h, cidx = tryCustomDeckImagesByCardIDFromData(data, cardID)\n        if front or back then\n          entry.front = (front and front ~= \"\") and front or nil\n          entry.back  = (back  and back  ~= \"\") and back  or nil\n          entry.w = w; entry.h = h; entry.i = cidx\n        end\n      end\n\n      entry.idx = idx\n      if inHead then items[#items + 1] = entry end\n      if inTail then tail[#tail + 1] = entry end\n    end\n  end\n\n  local anyName = false\n  for i = 1, math.min(#items, 10) do\n    if items[i].name and items[i].name ~= \"\" then anyName = true; break end\n  end\n  if not anyName then return nil end\n\n  return { total=total, shown=#items, items=items, tail=tail,\n           source=\"Deck+CustomDeck(HybridNamesFromGetObjects)\" }\nend\n\nlocal function deckPeekFromGetObjects(deckObj, data)\n  local list = safeGetContainerObjects(deckObj)\n  if not list then return nil end\n\n  local items = {}\n  local total = #list\n  local anyCardId = false\n  local anyName = false\n\n  -- DECK TAIL: both ends, one shared range rule -- see deckPeekHybrid above.\n  -- The loop variable is `pidx` because the body below declares an `idx` of its\n  -- own, and `it` is bound here now that this is a numeric walk. The two hint\n  -- flags are set while entries are built, so they now see the last 80 cards as\n  -- well as the first 80: containerPeekRaw's choice of path gets MORE evidence,\n  -- never less.\n  local headN, tailFrom, tail = AUTO.deckEnds(total)\n\n  for pidx = 1, total do\n    local inHead = (pidx <= headN)\n    local inTail = (tailFrom ~= nil and pidx >= tailFrom)\n    if inHead or inTail then\n      local it = list[pidx]\n\n      local cardID = it.cardID or it.CardID or it.cardId or it.CardId\n      if type(cardID) == \"number\" then anyCardId = true end\n\n      local nm = safeStr(it.nickname or it.name or it.Name or \"\")\n      if nm ~= \"\" then anyName = true end\n\n      local entry = {\n        name = nm,\n        guid = safeStr(it.guid or it.GUID or \"\"),\n        desc = safeStr(it.description or it.Description or \"\"),\n        tag  = \"Card\",\n        cardID = cardID,\n      }\n\n      if type(cardID) == \"number\" and data and data.CustomDeck then\n        local front, back, w, h, idx = tryCustomDeckImagesByCardIDFromData(data, cardID)\n        if front or back then\n          entry.front = (front and front ~= \"\") and front or nil\n          entry.back  = (back  and back  ~= \"\") and back  or nil\n          entry.w = w; entry.h = h; entry.i = idx\n        end\n      end\n\n      entry.idx = pidx\n      if inHead then items[#items + 1] = entry end\n      if inTail then tail[#tail + 1] = entry end\n    end\n  end\n\n  return { total=total, shown=#items, items=items, tail=tail,\n           source=\"Deck+CustomDeck(getObjects)\", _anyCardId=anyCardId, _anyName=anyName }\nend\n\nlocal function deckPeekFromDeckIDs(deckObj, data)\n  if not data or type(data.DeckIDs) ~= \"table\" or not data.CustomDeck then return nil end\n\n  local ids = data.DeckIDs\n  local items = {}\n  local total = #ids\n\n  -- DECK TAIL: both ends, one shared range rule -- see deckPeekHybrid above.\n  local headN, tailFrom, tail = AUTO.deckEnds(total)\n\n  for idx = 1, total do\n    local inHead = (idx <= headN)\n    local inTail = (tailFrom ~= nil and idx >= tailFrom)\n    if inHead or inTail then\n\n      local cardID = ids[idx]\n      if type(cardID) ~= \"number\" then cardID = tonumber(cardID) end\n\n      local entry = { name=\"\", guid=\"\", desc=\"\", tag=\"Card\", cardID=cardID }\n\n      if type(cardID) == \"number\" then\n        local front, back, w, h, cidx = tryCustomDeckImagesByCardIDFromData(data, cardID)\n        if front or back then\n          entry.front = (front and front ~= \"\") and front or nil\n          entry.back  = (back  and back  ~= \"\") and back  or nil\n          entry.w = w; entry.h = h; entry.i = cidx\n        end\n      end\n\n      entry.idx = idx\n      if inHead then items[#items + 1] = entry end\n      if inTail then tail[#tail + 1] = entry end\n    end\n  end\n\n  return { total=total, shown=#items, items=items, tail=tail,\n           source=\"Deck+CustomDeck(DeckIDs)\" }\nend\n\nlocal function containerPeekRaw(obj)\n  local t0 = os.clock()\n  if not obj then return nil end\n  local t = tostring(obj.tag or \"\")\n  local out = nil\n\n  -- HOW MANY ITEMS, once, for both caps and for the marker at the end of this\n  -- function (Spectator Tool Autodraw build only, 2026-09-08). Inside a fragment\n  -- build this rides AUTO.dataMemo, so it is ONE getQuantity however many\n  -- readers ask; outside one it is a cheap engine property read once.\n  local cqty = AUTO.containerQty(obj, AUTO.dataMemo)\n\n  -- COUNT ONLY, above AUTO.HUGE_CONTAINER_LIMIT (2026-09-08). obj.getObjects()\n  -- on this table's 2330-card bag builds a table of 2330 entries and the peek\n  -- keeps 80 of them, so past the limit it is not asked for at all -- and\n  -- safeGetContainerObjects declines for the same object, so deckPreviewSprite\n  -- answers nil too and the object falls back to its single image. `capped` is\n  -- stamped by the line at the end of this function, which the higher limit\n  -- here guarantees this container also clears.\n  if cqty > AUTO.HUGE_CONTAINER_LIMIT then\n    out = { total = cqty, shown = 0, items = {}, source = \"count\" }\n  end\n\n  if out == nil and t == \"Deck\" then\n    local data = tryGetData(obj)\n\n    local hybrid = deckPeekHybrid(obj, data)\n    if hybrid then out = hybrid end\n\n    if not out then\n      local peek1 = deckPeekFromGetObjects(obj, data)\n      if peek1 then\n        if not peek1._anyCardId then\n          local peek2 = deckPeekFromDeckIDs(obj, data)\n          if peek2 then out = peek2 else\n            peek1._anyCardId = nil\n            peek1._anyName = nil\n            out = peek1\n          end\n        else\n          peek1._anyCardId = nil\n          peek1._anyName = nil\n          out = peek1\n        end\n      end\n    end\n\n    if not out then\n      local peek2 = deckPeekFromDeckIDs(obj, data)\n      if peek2 then out = peek2 end\n    end\n  end\n\n  if not out and (t == \"Bag\" or t == \"InfiniteBag\") then\n    local data = tryGetData(obj)\n    if data and type(data.ContainedObjects) == \"table\" then\n      local contained = data.ContainedObjects\n      local items = {}\n      local n = 0\n      for _, co in ipairs(contained) do\n        n = n + 1\n        if n > MAX_CONTAINER_PEEK then break end\n        table.insert(items, enrichPeekItemFromContainedObject(co))\n      end\n      out = { total = #contained, shown = #items, items = items, source = \"ContainedObjects\" }\n    else\n      local list = safeGetContainerObjects(obj)\n      if list then\n        local items = {}\n        local n = 0\n        for _, it in ipairs(list) do\n          n = n + 1\n          if n > MAX_CONTAINER_PEEK then break end\n          table.insert(items, {\n            name = safeStr(it.nickname or it.name or it.Name or \"\"),\n            guid = safeStr(it.guid or it.GUID or \"\"),\n            desc = safeStr(it.description or it.Description or \"\"),\n            tag  = \"Unknown\",\n            cardID = it.cardID or it.CardID or it.cardId or it.CardId,\n          })\n        end\n        out = { total = #list, shown = #items, items = items, source = \"getObjects\" }\n      end\n    end\n  end\n\n  -- BIG CONTAINER CAP (Spectator Tool Autodraw build only, 2026-09-08). Above\n  -- AUTO.BIG_CONTAINER_LIMIT items tryGetData declined to serialise this\n  -- container, so whatever peek came out above was built from names alone: no\n  -- card art, no deck preview, no DeckIDs. Say so, and the site can explain the\n  -- gap rather than draw an empty container.\n  if out ~= nil and cqty > AUTO.BIG_CONTAINER_LIMIT then out.capped = true end\n\n  local dt = os.clock() - t0\n  if DEBUG_ENABLED then\n    PROF.peekRawCalls = PROF.peekRawCalls + 1\n    local g = safeStr(obj.getGUID())\n    local nm = safeName(obj)\n    profSlowPush(\"peeks\", \"containerPeekRaw tag=\" .. tostring(t) .. \" guid=\" .. g .. \" name=\" .. nm, dt)\n    if out and type(out) == \"table\" and type(out.shown) == \"number\" then\n      PROF.peekRawItemsTotal = PROF.peekRawItemsTotal + (out.shown or 0)\n    end\n  end\n\n  return out\nend\n\nlocal function containerPeekCached(obj, staleOk)\n  if not obj then return nil end\n  local guid = safeStr(obj.getGUID())\n  if guid == \"\" then\n    if DEBUG_ENABLED then PROF.peekMisses = PROF.peekMisses + 1 end\n    return containerPeekRaw(obj)\n  end\n\n  local now = os.clock()\n  local e = PEEK_CACHE[guid]\n  if not e then\n    e = { peek = nil, nextAt = 0 }\n    PEEK_CACHE[guid] = e\n  end\n\n  -- staleOk (FULL path): serve an already-populated peek regardless of TTL and\n  -- do NOT touch nextAt. See extractButtonsCached for the rationale; a never-\n  -- populated entry still falls through to a real read below.\n  if staleOk and e.populated then\n    if DEBUG_ENABLED then PROF.peekHits = PROF.peekHits + 1 end\n    return e.peek\n  end\n\n  if (not FORCE_PEEK_REFRESH) and now < (e.nextAt or 0) then\n    if DEBUG_ENABLED then PROF.peekHits = PROF.peekHits + 1 end\n    return e.peek\n  end\n\n  if DEBUG_ENABLED then PROF.peekMisses = PROF.peekMisses + 1 end\n  e.peek = containerPeekRaw(obj)\n  e.nextAt = now + PEEK_REFRESH_SECONDS\n  e.populated = true\n  return e.peek\nend\n\n-- =========================\n-- ZONE DISCOVERY (RAW + CACHED)\n-- =========================\nlocal function isDesiredZone(obj)\n  if not obj then return false end\n  if tostring(obj.tag or \"\") ~= \"Scripting\" then return false end\n  local name = safeStr(obj.getName())\n  if name == \"\" then return false end\n  return startsWith(name, ZONE_NAME_PREFIX)\nend\n\nlocal function zoneLabelFromName(name)\n  name = safeStr(name)\n  local p = name:find(ZONE_NAME_PREFIX .. ZONE_NAME_DELIM, 1, true)\n  if p == 1 then\n    return name:sub(#ZONE_NAME_PREFIX + #ZONE_NAME_DELIM + 1)\n  end\n  return name\nend\n\nlocal function findDesiredZonesRaw()\n  local zones = {}\n  for _, obj in ipairs(getAllObjects()) do\n    if isDesiredZone(obj) then table.insert(zones, obj) end\n  end\n  table.sort(zones, function(a, b)\n    local an = safeStr(a.getName())\n    local bn = safeStr(b.getName())\n    if an ~= bn then return an < bn end\n    return safeStr(a.getGUID()) < safeStr(b.getGUID())\n  end)\n  return zones\nend\n\n-- =========================\n-- 3DText SPLICE  (only in the \"Spectator 13 XML\" build)\n-- =========================\n-- The one place the tracked texts (see the 3DText TRACKING block far above) are\n-- turned back into zone members. It has two callers, AUTO.filtered and\n-- AUTO.spreadA, and every zone consumer reads its zone through one of them, so\n-- a text inside a zone is an ordinary member of it everywhere: snapshot, diff,\n-- dead-GUID sweep -- all but the round-robin, which considerObj deliberately\n-- skips for texts (see there). Nothing else downstream knows this feature exists.\n--\n-- `z.getObjects()` is left to throw exactly as it did before -- every call site\n-- wraps its read in a pcall and treats a throw as \"the zone reference is dying\",\n-- which is still the right answer.\n--\n-- `objs` is a fresh table from the engine on every call, so appending to it is\n-- safe and cannot accumulate.\n--\n-- Assigning nil to keys that already exist while iterating with pairs() is legal\n-- Lua; ADDING keys during the walk is not, and nothing here does.\nlocal function zoneObjectsPlusTexts(z)\n  local objs = z.getObjects()\n  if type(objs) ~= \"table\" then return objs end\n  for g in pairs(TEXT_GUIDS) do\n    local okO, o = pcall(function() return getObjectFromGUID(g) end)\n    if okO then\n      if o == nil then\n        -- Destroyed, or now inside a container: either way it is not on the\n        -- table, and this is the only place that ever notices.\n        TEXT_GUIDS[g] = nil\n        TEXT_LAST[g] = nil\n      else\n        -- One pcall for the whole read: a text dying between two calls just\n        -- means \"skip it this tick\", never an error and never a lost GUID.\n        pcall(function()\n          if o.name ~= \"3DText\" then\n            TEXT_GUIDS[g] = nil\n            TEXT_LAST[g] = nil\n            return\n          end\n          -- positionToLocal returns UNIT-CUBE coordinates (it divides by the\n          -- zone scale), so inside is |x| <= 0.5 and |z| <= 0.5 whatever the\n          -- zone's size. Texts sit on the zone FLOOR -- local y = -0.50 on the\n          -- nose -- so Y gets 0.55 of slack rather than 0.50.\n          -- AUTO.zoneAt (zones/) avoids positionToLocal on purpose; see its comment.\n          local lp = z.positionToLocal(o.getPosition())\n          if lp.x >= -0.5 and lp.x <= 0.5\n             and lp.z >= -0.5 and lp.z <= 0.5\n             and lp.y >= -0.55 and lp.y <= 0.55 then\n            objs[#objs + 1] = o\n            -- FRESHNESS. A text edit fires no engine event at all, so this walk\n            -- is the only thing that can notice one. Re-stamping nameSig is what\n            -- pushes it out: rrMetaSig already digests nameSig and stays a pure\n            -- BTN_CACHE lookup, which is the whole reason the signature is\n            -- folded in there rather than read on the hot path.\n            local sig = textSigFor(o)\n            if sig ~= TEXT_LAST[g] then\n              TEXT_LAST[g] = sig\n              local e = BTN_CACHE[g]\n              if e then stampNameScale(o, e) end\n            end\n          end\n        end)\n      end\n    end\n  end\n  return objs\nend\n\nlocal function refreshZoneCacheIfNeeded(force)\n  local t0 = os.clock()\n  local now = os.clock()\n  if force or ZONES_DIRTY or (CACHED_ZONES == nil) or now >= (nextZoneRescanAt or 0) then\n    CACHED_ZONES = findDesiredZonesRaw()\n    nextZoneRescanAt = now + ZONE_RESCAN_SECONDS\n    ZONES_DIRTY = false\n    -- HAND ZONES, on this rescan and nowhere else (Spectator Tool Autodraw\n    -- build only, 2026-09-18): one walk of the seat colours, at most once\n    -- every ZONE_RESCAN_SECONDS. See HAND ZONES ON THE BOARD in the AUTO\n    -- block for the whole of it, and for the TTS trap it is shaped around.\n    AUTO.handZonesRead()\n  end\n  local dt = os.clock() - t0\n  if DEBUG_ENABLED then profAdd(\"t_zonecache\", dt) end\n  return CACHED_ZONES or {}\nend\n\n-- CACHED_ZONES holds LIVE object references. The moment the user deletes a\n-- scripting zone those references go dangling and EVERY method on them throws\n-- \"Object reference not set to an instance of an object\" -- which, mid-build,\n-- aborts the whole payload.\n--\n-- ZONES_DIRTY narrows that window but cannot close it: onObjectDestroy fires\n-- BEFORE the object is actually gone, so a rescan landing in that same frame\n-- re-caches the dying zone via getAllObjects() AND clears the flag, leaving a\n-- dead reference in the cache for up to ZONE_RESCAN_SECONDS. Every consumer of\n-- CACHED_ZONES must therefore prove a zone is alive before touching it.\n--\n-- One getGUID() through pcall is enough to prove liveness: it throws HERE, where\n-- we can skip the zone, instead of halfway through a payload. Skipping also sets\n-- ZONES_DIRTY so the next tick rebuilds the cache from getAllObjects().\nlocal function zoneAlive(z)\n  if not z then return false end\n  -- isDestroyed() exists on modern TTS builds and is cheaper than provoking a\n  -- throw, but do NOT depend on it: index it inside the pcall too (indexing a\n  -- dangling reference can itself throw) and fall through to the getGUID()\n  -- probe, which is the real guard, whenever the API is missing.\n  local okD, dead = pcall(function() return z.isDestroyed and z.isDestroyed() end)\n  if okD and dead == true then\n    ZONES_DIRTY = true\n    return false\n  end\n  local okG, g = pcall(function() return z.getGUID() end)\n  if (not okG) or type(g) ~= \"string\" or g == \"\" then\n    ZONES_DIRTY = true\n    return false\n  end\n  return true\nend\n\n-- =========================\n-- HAND ZONE ITERATION\n-- =========================\n-- A player can own multiple hand zones. Call fn(handObjs, handIdx) once per\n-- zone, with handIdx 1-based and handObjs the (possibly empty) object list for\n-- that zone. When the player has a single zone we call getHandObjects() with no\n-- argument so behavior on older/edge APIs stays byte-identical to pre-multi-hand\n-- code. This factors the getHandCount() pcall boilerplate into one place.\nlocal function forEachHandZone(p, fn)\n  -- HAND-LESS SEAT (Spectator Tool Autodraw build only, 2026-09-15). A seated\n  -- colour with no hand zone -- Black, the GM seat, in most mods -- reports a\n  -- hand count of 0, and asking it for hand objects anyway raises a .NET\n  -- NullReferenceException that pcall does NOT catch: the base treated 0 as\n  -- \"one hand\" and made the call, and that killed the tool at load and again at\n  -- room create (seen in game 2026-09-15; a pcall around the read changed\n  -- nothing). So the count is used EXACTLY when it can be read: 0 hands means\n  -- the seat is visited once with an empty hand and no engine call is made.\n  -- The pcalls stay for the errors pcall can catch. Only when the count itself\n  -- cannot be read (an old TTS without getHandCount) does the base rule of one\n  -- hand apply.\n  -- ... and a player who is NOT seated (the host gone Grey while broadcasting)\n  -- has no hand either, whatever the count says: Player.getPlayers() can still\n  -- list them, and asking a spectator for hand objects is the same uncatchable\n  -- raise. An unreadable `seated` (old TTS) falls through to the count.\n  local okS, seated = pcall(function() return p.seated end)\n  if okS and seated == false then fn({}, 1); return end\n  local okHC, n = pcall(function() return p.getHandCount() end)\n  if okHC and type(n) == \"number\" then\n    if n <= 0 then fn({}, 1); return end\n    local hc = math.floor(n)\n    for handIdx = 1, hc do\n      local okH, handObjs = pcall(function()\n        return (hc > 1 and p.getHandObjects(handIdx) or p.getHandObjects())\n      end)\n      if not okH or type(handObjs) ~= \"table\" then handObjs = {} end\n      fn(handObjs, handIdx)\n    end\n    return\n  end\n  local okH, handObjs = pcall(function() return p.getHandObjects() end)\n  if not okH or type(handObjs) ~= \"table\" then handObjs = {} end\n  fn(handObjs, 1)\nend\n\n-- =========================\n-- ROUND ROBIN\n-- =========================\n-- Dead-GUID cache sweep. The four guid-keyed caches (BTN_CACHE, PEEK_CACHE,\n-- FRAG_CACHE, SHUFFLE_EPOCH) only ever GAIN entries on the hot paths; the sole\n-- per-entry removal is the diff builder's remove branch, which misses objects\n-- whose removal is absorbed by a periodic full and everything inside a deleted\n-- zone (zoneRemoved). Card games destroy objects constantly -- every deck\n-- restack/merge/split kills one GUID and mints another -- so over a long game\n-- the leftovers grow without bound and MoonSharp GC time grows with them: the\n-- \"game gets laggier the longer it lasts\" bug. rebuildRoundRobinLists already\n-- walks every tracked object (zones + hands) every RR_REBUILD_SECONDS, so\n-- sweeping against that walk's seenObj set is nearly free and also self-heals\n-- the two stranded paths above. If a future version ever caches guids for\n-- objects OUTSIDE zones/hands, exempt them here or they will be evicted (and\n-- re-read) every sweep.\n--\n-- Collect-then-delete so no key is assigned while pairs() is walking the table.\nlocal function sweepDeadGuids(seen, cache)\n  local doomed = nil\n  for g in pairs(cache) do\n    if not seen[g] then\n      doomed = doomed or {}\n      doomed[#doomed + 1] = g\n    end\n  end\n  if not doomed then return 0 end\n  for i = 1, #doomed do cache[doomed[i]] = nil end\n  return #doomed\nend\n\nlocal function rebuildRoundRobinLists()\n  local t0 = os.clock()\n  local zones = refreshZoneCacheIfNeeded(false)\n\n  RR_OBJECTS = {}\n  RR_CONTAINERS = {}\n  rrObjIdx = 1\n  rrContIdx = 1\n  -- Cleared with the lists they shadow (Spectator 13 XML build only). A rebuild\n  -- lists only the zones' LIVE objects, so every death recorded before it is moot\n  -- for THESE lists the moment it finishes -- which is exactly what the capture\n  -- stamp a few lines below says, and what lets AUTO.deathPrune drop the record.\n  --\n  -- visited / contVisited are deliberately NOT cleared here, and that is the\n  -- whole of the LAP BY GUID fix: the lap is a pass over the OBJECTS, not over\n  -- the list slots, so it has to outlive the list being thrown away and rebuilt\n  -- every 10 s. Clearing them here would restart the lap six times a minute and\n  -- put back the starvation this replaced -- only the first 240 objects and 40\n  -- containers of each fresh list would ever be reached. They are emptied by\n  -- rrNextIdx when a lap genuinely completes, and at room create.\n  RR_META.guids = {}; RR_META.contGuids = {}\n  -- THE LISTS' CAPTURE STAMP (Spectator Tool Autodraw build only, 2026-09-13).\n  -- The lists this rebuild is about to fill hold references taken now, so every\n  -- death recorded before now is behind them. Stamped at the TOP of the rebuild\n  -- on purpose: nothing can die inside one synchronous call.\n  AUTO.rrAt = AUTO.deathSeq\n  -- AUTO.hotSig gains a key for every object the change gate walks, so it is\n  -- thrown away here and rebuilt from the objects that are actually moving\n  -- (Spectator Tool Autodraw build only). See AUTO.hotPrune.\n  AUTO.hotPrune()\n  -- ... and the ordinary diff's re-read gate, for the same reason and in the\n  -- same place (2026-09-08). Both of its maps gain a key for every object the\n  -- change gate walks; AUTO.gatePrune keeps only the GUIDs the diff baseline\n  -- still holds, and a pruned entry only ever costs a re-read.\n  AUTO.gatePrune()\n\n  local seenObj = {}\n  local seenCont = {}\n\n  local function considerObj(o)\n    if not o or not o.getGUID then return end\n    local g = safeStr(o.getGUID())\n    if g == \"\" or seenObj[g] then return end\n    seenObj[g] = true\n    -- 3DText never rides the round-robin (Spectator 13 XML build): it has no\n    -- buttons to refresh and nothing to peek, and a DELETED text's reference\n    -- raises an uncatchable .NET null-reference the moment rrStepButtons\n    -- touches it -- killing the poll tick every other tick until the next\n    -- rebuild (seen in game 2026-09-02). It stays in seenObj so its caches are\n    -- not swept as dead; its own metadata is stamped by the splice helper.\n    if TEXT_GUIDS[g] then return end\n    RR_OBJECTS[#RR_OBJECTS+1] = o\n    RR_META.guids[#RR_META.guids+1] = g\n\n    local t = tostring(o.tag or \"\")\n    if (t == \"Deck\" or t == \"Bag\" or t == \"InfiniteBag\") and (not seenCont[g]) then\n      seenCont[g] = true\n      RR_CONTAINERS[#RR_CONTAINERS+1] = o\n      RR_META.contGuids[#RR_META.contGuids+1] = g\n    end\n  end\n\n  -- True only if every zone answered this pass, i.e. seenObj is the COMPLETE\n  -- live tracked set. The sweep below must not run off a partial walk.\n  local sawEveryZone = true\n\n  for _, z in ipairs(zones) do\n    if zoneAlive(z) then\n      local ok, objs = pcall(function() return AUTO.filtered(z) end)\n      if ok and type(objs) == \"table\" then\n        for _, o in ipairs(objs) do considerObj(o) end\n      else\n        -- getObjects() threw on a zone whose getGUID() had just answered: the\n        -- reference is dying between calls, so stop trusting the cache.\n        ZONES_DIRTY = true\n        sawEveryZone = false\n      end\n    else\n      -- Dead/dying zone: its members were not walked, so seenObj is incomplete\n      -- this pass. zoneAlive already flagged ZONES_DIRTY; the next rebuild runs\n      -- from a rescanned zone list and sweeps then.\n      sawEveryZone = false\n    end\n  end\n\n  for _, p in ipairs(Player.getPlayers()) do\n    forEachHandZone(p, function(handObjs)\n      for _, o in ipairs(handObjs) do considerObj(o) end\n    end)\n  end\n\n  -- Sweep the guid-keyed caches against the live set just walked -- but only\n  -- from a complete walk: after an unreadable zone, seenObj is missing that\n  -- zone's still-live objects, and evicting their warm entries would force a\n  -- pointless re-read of every button/peek and a re-encode of every fragment\n  -- in it (correctness would survive either way; entries re-seed on next\n  -- touch). ZONES_DIRTY is set on every incomplete path, so a complete\n  -- rebuild -- and the deferred sweep -- follows once the zone list settles.\n  if sawEveryZone then\n    local removed = sweepDeadGuids(seenObj, BTN_CACHE)\n      + sweepDeadGuids(seenObj, PEEK_CACHE)\n      + sweepDeadGuids(seenObj, FRAG_CACHE)\n      + sweepDeadGuids(seenObj, SHUFFLE_EPOCH)\n      + sweepDeadGuids(seenObj, xmlPrev)\n      + sweepDeadGuids(seenObj, xmlRev)\n      + sweepDeadGuids(seenObj, TEXT_LAST)\n    if DEBUG_ENABLED and removed > 0 then\n      PROF.cacheSweepRemoved = PROF.cacheSweepRemoved + removed\n    end\n  end\n\n  local dt = os.clock() - t0\n  if DEBUG_ENABLED then\n    PROF.rrRebuilds = PROF.rrRebuilds + 1\n    profAdd(\"t_rr_rebuild\", dt)\n    profSlowPush(\"poll\", \"RR rebuild lists objs=\" .. tostring(#RR_OBJECTS) .. \" cont=\" .. tostring(#RR_CONTAINERS), dt)\n  end\nend\n\n-- Round-robin lap walker (Spectator 13 XML build only). Returns the index of\n-- the next entry that is neither visited this lap nor marked dead, plus the\n-- advanced cursor. When a full pass finds nothing, the lap is complete: the\n-- visited set is replaced with a fresh table (never mutated while iterated)\n-- and ONE more pass runs, so a small table still sees every object once per\n-- tick instead of losing a tick at each lap boundary. Returns nil only when\n-- every entry is dead. Cost: hash lookups only -- no TTS call is made on a\n-- skipped entry, which is what keeps this safe on a deleted reference.\nlocal function rrNextIdx(guids, visitedKey, idx)\n  local n = #guids\n  local visited = RR_META[visitedKey]\n  for _ = 1, 2 do\n    for _ = 1, n do\n      if idx > n then idx = 1 end\n      local i = idx\n      idx = idx + 1\n      local g = guids[i]\n      if g and not visited[g] and not AUTO.deadRef(g, AUTO.rrAt) then\n        return i, idx\n      end\n    end\n    -- Nothing left unvisited: lap over, start the next one.\n    visited = {}\n    RR_META[visitedKey] = visited\n  end\n  return nil, idx\nend\n\n-- =========================\n-- AUTODRAW  (only in the \"Spectator Tool Autodraw\" build)\n-- =========================\n-- Feature folder: zones/ (tts/lua/zones/README.md has the why). In short:\n-- pressing Broadcast on a table with no hand-drawn\n-- SpectatorTool zone spawns one fitted around everything, so the tool works with\n-- no setup at all; it is deleted again the moment broadcasting stops.\n\n-- Object NAMES and TAGS that are themselves zones. A zone must never contribute\n-- to the box we are fitting -- a fog-of-war or layout zone is often table-sized,\n-- and fitting to it would make the auto zone grow every time it ran. Both the\n-- name and the tag are tested because TTS reports the two differently depending\n-- on how the zone was made (a spawned \"ScriptingTrigger\" answers name\n-- \"ScriptingTrigger\" and tag \"Scripting\").\nAUTO.ZONEISH = {\n  ScriptingTrigger = true, HandTrigger = true, FogOfWar = true,\n  RandomizeTrigger = true, LayoutZone = true,\n  Hand = true, Scripting = true, Fog = true, Randomize = true, Layout = true,\n}\n\n-- PURE MATH, deliberately separated from the TTS reads in AUTO.fit below so it\n-- can be tested in a real Lua VM with no game running (tests/lua/autodraw/).\n-- `entries` is a list of { x, y, z, sx, sz, skip }: a point when skip is true,\n-- otherwise an axis-aligned footprint of sx by sz centred on x, z.\n--\n-- Returns { cx, cy, cz, sx, sy, sz, n } -- centre, full size, and how many\n-- entries were actually used.\nAUTO.fitBox = function(entries)\n  local MARGIN = 5    -- breathing room on each side, so an object on the rim is\n                      -- not half-in / half-out of the zone\n  local FLOOR = 80    -- a table with three cards on it still gets a usable board\n  local minX, maxX, minZ, maxZ, minY, maxY\n  local n = 0\n  for i = 1, #entries do\n    local e = entries[i]\n    if type(e) == \"table\" and type(e.x) == \"number\" and type(e.y) == \"number\"\n       and type(e.z) == \"number\" then\n      local hx, hz = 0, 0\n      if not e.skip then\n        hx = (tonumber(e.sx) or 0) / 2\n        hz = (tonumber(e.sz) or 0) / 2\n      end\n      local x0, x1 = e.x - hx, e.x + hx\n      local z0, z1 = e.z - hz, e.z + hz\n      if minX == nil or x0 < minX then minX = x0 end\n      if maxX == nil or x1 > maxX then maxX = x1 end\n      if minZ == nil or z0 < minZ then minZ = z0 end\n      if maxZ == nil or z1 > maxZ then maxZ = z1 end\n      if minY == nil or e.y < minY then minY = e.y end\n      if maxY == nil or e.y > maxY then maxY = e.y end\n      n = n + 1\n    end\n  end\n  -- Nothing qualified (empty table, or every object was junk): a default board\n  -- at the origin is still better than no zone, because the user can drag it.\n  if n == 0 then\n    return { cx = 0, cy = 10, cz = 0, sx = FLOOR, sy = 20, sz = FLOOR, n = 0 }\n  end\n  minX = minX - MARGIN; maxX = maxX + MARGIN\n  minZ = minZ - MARGIN; maxZ = maxZ + MARGIN\n  local cx = (minX + maxX) / 2\n  local cz = (minZ + maxZ) / 2\n  local w = maxX - minX\n  local d = maxZ - minZ\n  -- Widen about the CENTRE, so the floor never shifts the board off the objects.\n  if w < FLOOR then w = FLOOR end\n  if d < FLOOR then d = FLOOR end\n  -- Y is generous upward: cards get picked up and held well above the table, and\n  -- an object that leaves the zone vertically would flicker out of the payload.\n  local y0 = minY - 3\n  local y1 = maxY + 25\n  if (y1 - y0) < 20 then\n    local mid = (y0 + y1) / 2\n    y0 = mid - 10\n    y1 = mid + 10\n  end\n  return { cx = cx, cy = (y0 + y1) / 2, cz = cz,\n           sx = w, sy = y1 - y0, sz = d, n = n }\nend\n\n-- ONE getAllObjects() walk, at Broadcast time only -- never on a tick. Collects\n-- what fitBox needs and nothing else. Every engine read is pcall'd: this runs\n-- over every object on the table, including whatever mod-specific thing is\n-- currently misbehaving, and a raise here would abort the Broadcast.\nAUTO.fit = function()\n  local entries = {}\n  local okA, all = pcall(function() return getAllObjects() end)\n  if not okA or type(all) ~= \"table\" then return AUTO.fitBox(entries) end\n  for _, o in ipairs(all) do\n    local keep = true\n    local okG, g = pcall(function() return o.getGUID() end)\n    if okG and type(g) == \"string\" and g == AUTO.selfGuid then keep = false end\n    local nm, tg = \"\", \"\"\n    if keep then\n      local okN, n = pcall(function() return o.name end)\n      if okN and type(n) == \"string\" then nm = n end\n      local okT, t = pcall(function() return tostring(o.tag or \"\") end)\n      if okT and type(t) == \"string\" then tg = t end\n      if AUTO.ZONEISH[nm] or AUTO.ZONEISH[tg] then keep = false end\n    end\n    if keep then\n      local okP, p = pcall(function() return o.getPosition() end)\n      if not okP or type(p) ~= \"table\" or type(p.x) ~= \"number\"\n         or type(p.y) ~= \"number\" or type(p.z) ~= \"number\" then\n        keep = false\n      elseif p.x > 250 or p.x < -250 or p.z > 250 or p.z < -250\n             or p.y < -20 or p.y > 150 then\n        -- Off the table entirely: a stray object parked in the void would\n        -- stretch the box across half the world and shrink the real board to a\n        -- speck on the site.\n        keep = false\n      end\n      if keep then\n        -- POSITION ONLY for a 3DText: its getBounds() is junk (no collider), the\n        -- same reason zone.getObjects() cannot see one.\n        local e = { x = p.x, y = p.y, z = p.z, skip = true }\n        if nm ~= \"3DText\" then\n          local okB, b = pcall(function() return o.getBounds() end)\n          if okB and type(b) == \"table\" and type(b.center) == \"table\"\n             and type(b.size) == \"table\"\n             and type(b.center.x) == \"number\" and type(b.center.y) == \"number\"\n             and type(b.center.z) == \"number\"\n             and type(b.size.x) == \"number\" and type(b.size.z) == \"number\"\n             and b.size.x <= 120 and b.size.z <= 120 then\n            -- ... and position only for anything ENORMOUS too: a table surface\n            -- or a backdrop mesh is 100s of units wide and would swallow the fit.\n            e = { x = b.center.x, y = b.center.y, z = b.center.z,\n                  sx = b.size.x, sz = b.size.z, skip = false }\n          end\n        end\n        entries[#entries + 1] = e\n      end\n    end\n  end\n  return AUTO.fitBox(entries)\nend\n\n-- How many SpectatorTool zones the USER drew. findDesiredZonesRaw already does\n-- the prefix and tag test, so this only has to discount our own zone -- by NAME,\n-- not by GUID, because a zone left behind by a crash has a GUID we never saw.\nAUTO.hasHandDrawnZones = function()\n  local okZ, zones = pcall(function() return findDesiredZonesRaw() end)\n  if not okZ or type(zones) ~= \"table\" then return 0 end\n  local n = 0\n  for _, z in ipairs(zones) do\n    local okN, nm = pcall(function() return safeStr(z.getName()) end)\n    if okN and nm ~= AUTO.name then n = n + 1 end\n  end\n  return n\nend\n\n-- Called from the Broadcast toggle, BEFORE the room is created, so the zone is\n-- in place by the time the create callback runs its first zone scan.\nAUTO.spawn = function()\n  local drawn = AUTO.hasHandDrawnZones()\n  if drawn > 0 then\n    -- The user's own zones always win. Adding a table-wide zone on top of them\n    -- would publish every object twice and bury their layout under one big board.\n    print(\"[Spectator] Autodraw: using \" .. tostring(drawn) .. \" hand-drawn zone(s).\")\n    return\n  end\n  local b = AUTO.fit()\n  local okS = pcall(function()\n    spawnObject({\n      type = \"ScriptingTrigger\",\n      position = { b.cx, b.cy, b.cz },\n      rotation = { 0, 0, 0 },\n      scale = { b.sx, b.sy, b.sz },\n      sound = false,\n      callback_function = function(z)\n        pcall(function() z.setName(AUTO.name) end)\n        local okG, g = pcall(function() return z.getGUID() end)\n        if okG and type(g) == \"string\" and g ~= \"\" then AUTO.zoneGuid = g end\n        -- Force the zone cache to notice it NOW rather than up to\n        -- ZONE_RESCAN_SECONDS later: the spawn fires no event this tool listens\n        -- for that would flag the cache itself.\n        CACHED_ZONES = nil\n        ZONES_DIRTY = true\n        nextZoneRescanAt = 0\n      end,\n    })\n  end)\n  if not okS then\n    print(\"[Spectator] Autodraw: could not spawn the zone. Draw one by hand.\")\n    return\n  end\n  print(\"[Spectator] Autodraw zone \"\n        .. string.format(\"%.0f\", b.sx) .. \"x\" .. string.format(\"%.0f\", b.sz)\n        .. \" units at (\" .. string.format(\"%.1f\", b.cx)\n        .. \", \" .. string.format(\"%.1f\", b.cz)\n        .. \") covering \" .. tostring(b.n) .. \" objects.\")\nend\n\n-- Delete the zone we spawned. Called from EVERY path that stops broadcasting\n-- (the toggle, the terminal 404/401, and a failed room create), from onDestroy,\n-- and -- by name, via AUTO.sweep -- from onLoad. Clearing zoneGuid first makes\n-- a second call a no-op, so hooking several paths cannot double-destruct.\nAUTO.destroy = function()\n  -- FIRST, and deliberately ABOVE the early return below, which fires whenever\n  -- we never spawned a zone (the user drew their own). Every path that stops\n  -- broadcasting comes through here, and a warm-up left running would keep\n  -- painting a percentage on a dead panel and would suppress every publish of\n  -- the NEXT room. Dropping the list also releases the object references it\n  -- holds, which is the only thing in this state that costs memory.\n  AUTO.setup.active = false\n  AUTO.setup.list = nil\n  -- HOT TICKS, for the same reason and on the same \"above the early return\"\n  -- rule: the parity, the hot set and the two flags all belong to the broadcast\n  -- that is stopping. Left standing, hotPending would open the publish gate of\n  -- the NEXT room and AUTO.hot would keep object references alive for nothing.\n  AUTO.tick = 0\n  AUTO.hot = {}\n  AUTO.hotSig = {}\n  AUTO.hotPending = false\n  AUTO.hotOnly = false\n  AUTO.fullPending = false\n  -- THE BIG DECK ROSTER (2026-09-17), same rule: it describes the big decks of\n  -- the broadcast that is stopping, and it holds a reference to each of their\n  -- CustomDeck sheet tables.\n  AUTO.deckRoster = {}\n  -- The ordinary diff's re-read gate (2026-09-08), same rule: it describes the\n  -- objects of the broadcast that is stopping, and a stale entry left standing\n  -- would let the NEXT room's first diff reuse a signature it never computed.\n  AUTO.gateSig = {}\n  AUTO.diffGate = {}\n  AUTO.lapCursor = 0\n  -- ... and the scan's per-zone lists, which hold a live reference to every\n  -- object of every zone: left standing they would keep the whole of the\n  -- stopped broadcast's table alive, and the next room's first diff would walk\n  -- a list nothing had refreshed.\n  AUTO.scanG = {}\n  AUTO.scanO = {}\n  AUTO.scanN = {}\n  AUTO.scanGen = {}\n  AUTO.zoneOrder = {}\n  -- FLIGHT, same rule: the pending list holds live object references, and a\n  -- callback armed by the broadcast that is stopping must find a generation it\n  -- does not recognise and return without working.\n  AUTO.flightPending = {}\n  AUTO.flightLoop.gen = (AUTO.flightLoop.gen or 0) + 1\n  AUTO.flightLoop.armed = false\n  -- PRESENCE, same rule (2026-09-15): the seats, the pending departures and the\n  -- buffered pings belong to the broadcast that is stopping, and a standalone\n  -- post still in the air finds a state table it does not recognise and returns.\n  AUTO.presReset()\n  local g = AUTO.zoneGuid\n  AUTO.zoneGuid = nil\n  if type(g) ~= \"string\" or g == \"\" then return end\n  pcall(function()\n    local z = getObjectFromGUID(g)\n    if z then z.destruct() end\n  end)\n  CACHED_ZONES = nil\n  ZONES_DIRTY = true\n  nextZoneRescanAt = 0\nend\n\n-- The load-time cleanup. A save taken mid-broadcast has our zone IN it, and\n-- broadcasting is always OFF after a load, so that zone is an orphan: it would\n-- sit there forever, and the next Broadcast would see it as a \"hand-drawn\" zone\n-- and refuse to fit a fresh one. Matched on the exact name, because its GUID\n-- died with the previous session.\nAUTO.sweep = function()\n  AUTO.zoneGuid = nil\n  local okZ, zones = pcall(function() return findDesiredZonesRaw() end)\n  if not okZ or type(zones) ~= \"table\" then return 0 end\n  local n = 0\n  for _, z in ipairs(zones) do\n    local okN, nm = pcall(function() return safeStr(z.getName()) end)\n    if okN and nm == AUTO.name then\n      pcall(function() z.destruct() end)\n      n = n + 1\n    end\n  end\n  if n > 0 then\n    CACHED_ZONES = nil\n    ZONES_DIRTY = true\n    nextZoneRescanAt = 0\n    print(\"[Spectator] Autodraw: removed \" .. tostring(n) .. \" leftover auto zone(s).\")\n  end\n  return n\nend\n\n-- WHAT MUST NEVER BE PUBLISHED. Rebuilt at the top of every poll tick, because\n-- a card moves in and out of a hand between one tick and the next:\n--   * the tool itself -- an auto zone fitted to the whole table contains it, and\n--     a spectator does not need to look at the broadcaster's own control panel.\n--     ALWAYS, whatever any switch says;\n--   * every object in every player's hand -- UNLESS the hand zones are being\n--     shown AND Reveal Hidden is on (HAND ZONES ON THE BOARD, 2026-09-18). A\n--     card in a hand is face UP to its owner, so itemForObject would not redact\n--     it: with Reveal Hidden OFF publishing one would be exactly the leak that\n--     button promises not to make, and the cards stay out while the boxes are\n--     still drawn. With BOTH on the cards are the point -- a hand zone TTS draws\n--     from above is the box and what is in it -- and they are published where\n--     they really are. They ride players[].hand as well, exactly as before, so\n--     the site's hand widgets are untouched either way.\n-- One walk of the seated players per tick, and none at all while the cards are\n-- wanted; the hand lists are the same ones rebuildRoundRobinLists already reads.\nAUTO.refreshExcl = function()\n  local excl = {}\n  if AUTO.selfGuid ~= \"\" then excl[AUTO.selfGuid] = true end\n  if not (AUTO.HAND_ZONES and REVEAL_HIDDEN) then\n    local okP, players = pcall(function() return Player.getPlayers() end)\n    if okP and type(players) == \"table\" then\n      for _, p in ipairs(players) do\n        pcall(function()\n          forEachHandZone(p, function(handObjs)\n            for _, o in ipairs(handObjs) do\n              local okG, g = pcall(function() return o.getGUID() end)\n              if okG and type(g) == \"string\" and g ~= \"\" then excl[g] = true end\n            end\n          end)\n        end)\n      end\n    end\n  end\n  -- Replaced, never mutated in place: a consumer half-way through a walk keeps\n  -- the table it started with.\n  AUTO.excl = excl\nend\n\n-- The zone member list, minus everything excluded. Four of the five zone\n-- consumers go through here; the fifth -- the per-tick change gate -- filters\n-- inline instead, because it already has each object's GUID in hand and must not\n-- pay a second getGUID() per object per tick.\n--\n-- The extra getGUID() per object here is paid at publish and 10-second rates,\n-- not per tick. z.getObjects() is still left to THROW exactly as before: all\n-- four call sites wrap this in a pcall and read a throw as \"the zone reference\n-- is dying\", which is still the right answer.\nAUTO.filtered = function(z)\n  -- HOT TICK (2026-09-07): the objects of this zone that are MOVING, and nothing\n  -- else. The flag is set only around a hot publish, where buildDiffSnapshot\n  -- carries every OTHER baseline entry forward as unchanged -- so a short list\n  -- here is not a zone that lost its objects. A FULL is never built under this\n  -- flag (pollLoop skips the hot publish when one is due), because a full walked\n  -- through here would publish a board with the rest of the table missing.\n  if AUTO.hotOnly then return AUTO.hotListFor(z) end\n  local objs = zoneObjectsPlusTexts(z)\n  if type(objs) ~= \"table\" then return objs end\n  local out = {}\n  for i = 1, #objs do\n    local o = objs[i]\n    local okG, g = pcall(function() return o.getGUID() end)\n    if not (okG and type(g) == \"string\" and AUTO.excl[g]) then\n      out[#out + 1] = o\n    end\n  end\n  return out\nend\n\n-- =========================\n-- DECK TAIL  (2026-09-07)\n-- =========================\n-- Which physical positions of a deck the peek keeps: the first\n-- MAX_CONTAINER_PEEK, and -- only when the deck holds more than that many -- the\n-- last MAX_CONTAINER_PEEK as well. Returns headN, tailFrom and the table the\n-- tail entries go in (nil for \"no tail\", which is also what leaves `tail` off\n-- the wire).\n--\n-- ONE copy of the rule, called by all three deck peek helpers, because the\n-- site's merge depends on head and tail agreeing about where they meet: for a\n-- deck of 81..160 the two ranges OVERLAP and the same card appears in both, and\n-- the site de-duplicates on `idx`; past 160 they do not touch and the site draws\n-- an ellipsis between them. It hangs off AUTO for the reason everything does --\n-- the chunk is close to Lua's 200-per-scope limit, and a named rule in\n-- tts/build/rules.json pins the count -- and is a plain function of\n-- a number, which is what makes it testable with no game running.\nAUTO.deckEnds = function(total)\n  if type(total) ~= \"number\" or total <= MAX_CONTAINER_PEEK then\n    return total, nil, nil\n  end\n  return MAX_CONTAINER_PEEK, total - MAX_CONTAINER_PEEK + 1, {}\nend\n\n-- =========================\n-- INPUTS AND DECALS  (2026-09-07)\n-- =========================\n-- Read ONCE per round-robin visit, from refreshButtonsEntry -- the one function\n-- that ever writes e.buttons -- and never again: everything downstream (the two\n-- item builders, and AUTO.stampIoSig below, whose answer rrMetaSig folds into\n-- the per-object per-tick signature) is a pure BTN_CACHE lookup. That is the\n-- same discipline the buttons keep, and the only thing that makes this\n-- affordable at all; see the module docstring for the measured cost.\n--\n-- Rounded by the SAME helpers extractButtonsRaw uses (vec3Round / rotRound /\n-- POS_DECIMALS / ROT_DECIMALS / rgbaOrNil), so an input's geometry cannot be\n-- encoded one way and a button's another.\n--\n-- nil, not an empty list, when there is nothing: the field is then absent from\n-- the wire entirely, which for most objects is every field this feature adds.\nAUTO.readInputs = function(obj)\n  local t0 = os.clock()\n  local ok, ins = pcall(function() return obj.getInputs() end)\n  local dt = os.clock() - t0\n  if DEBUG_ENABLED then\n    PROF.inputRawCalls = PROF.inputRawCalls + 1\n    -- LAZY LABEL (optimization candidate p0, 2026-09-21; Debug only).\n    -- The label costs a getGUID and a getName/getDescription, and\n    -- profSlowPush keeps it only when the read crossed DEBUG_SLOW_MS --\n    -- so it is built only then. Same test profSlowPush makes, made first.\n    if ms(dt) >= DEBUG_SLOW_MS then\n      profSlowPush(\"buttons\", \"getInputs guid=\" .. safeStr(obj.getGUID()) .. \" name=\" .. safeName(obj), dt)\n    end\n  end\n  if not ok or type(ins) ~= \"table\" or #ins == 0 then return nil end\n  local out = {}\n  for i, b in ipairs(ins) do\n    if type(b) == \"table\" then\n      out[#out + 1] = {\n        index = i - 1,\n        label = tostring(b.label or \"\"),\n        -- What the player typed. Through tostring, because an untouched field\n        -- answers nil and the site is typed for a string.\n        value = tostring(b.value or \"\"),\n        alignment = tonumber(b.alignment) or 0,\n\n        position  = vec3Round(vec3FromAny(b.position), POS_DECIMALS),\n        rotation  = rotRound(vec3FromAny(b.rotation), ROT_DECIMALS),\n        scale     = vec3Round(vec3FromAny(b.scale), POS_DECIMALS),\n\n        width     = tonumber(b.width) or 0,\n        height    = tonumber(b.height) or 0,\n        font_size = tonumber(b.font_size) or 0,\n\n        color      = rgbaOrNil(b.color),\n        font_color = rgbaOrNil(b.font_color),\n      }\n    end\n  end\n  if #out == 0 then return nil end\n  return out\nend\n\n-- The object's decals. A decal with no url is nothing the site can draw, so it\n-- is dropped here rather than shipped and skipped there.\nAUTO.readDecals = function(obj)\n  local t0 = os.clock()\n  local ok, ds = pcall(function() return obj.getDecals() end)\n  local dt = os.clock() - t0\n  if DEBUG_ENABLED then\n    PROF.decalRawCalls = PROF.decalRawCalls + 1\n    -- LAZY LABEL (optimization candidate p0, 2026-09-21; Debug only).\n    -- The label costs a getGUID and a getName/getDescription, and\n    -- profSlowPush keeps it only when the read crossed DEBUG_SLOW_MS --\n    -- so it is built only then. Same test profSlowPush makes, made first.\n    if ms(dt) >= DEBUG_SLOW_MS then\n      profSlowPush(\"buttons\", \"getDecals guid=\" .. safeStr(obj.getGUID()) .. \" name=\" .. safeName(obj), dt)\n    end\n  end\n  if not ok or type(ds) ~= \"table\" or #ds == 0 then return nil end\n  local out = {}\n  for _, d in ipairs(ds) do\n    if type(d) == \"table\" then\n      local url = tostring(d.url or \"\")\n      if url ~= \"\" then\n        out[#out + 1] = {\n          name = tostring(d.name or \"\"),\n          url = url,\n          position = vec3Round(vec3FromAny(d.position), POS_DECIMALS),\n          rotation = rotRound(vec3FromAny(d.rotation), ROT_DECIMALS),\n          scale    = vec3Round(vec3FromAny(d.scale), POS_DECIMALS),\n        }\n      end\n    end\n  end\n  if #out == 0 then return nil end\n  return out\nend\n\n-- The digest rrMetaSig folds in. Stamped on the visit that just paid for the two\n-- reads, exactly as stampButtonGeom is, so the hot path only ever reads the\n-- string back. Without it a player typing into a field, or a decal being moved,\n-- would change nothing any signature could see and the object would keep serving\n-- its cached fragment until the fragment TTL expired (~22-34 min).\n--\n-- Only ever compared against itself, so values go in verbatim -- newlines,\n-- semicolons and all -- the same argument textSigFor makes.\nAUTO.stampIoSig = function(e)\n  local ins, dec = e.inputs, e.decals\n  if type(ins) ~= \"table\" and type(dec) ~= \"table\" then e.ioSig = \"\" return end\n  local function c4(c)\n    if type(c) ~= \"table\" then return \"-\" end\n    return q3(c.r) .. \",\" .. q3(c.g) .. \",\" .. q3(c.b) .. \",\" .. q3(c.a)\n  end\n  local function xyz(v)\n    if type(v) ~= \"table\" then return \"-\" end\n    return q3(v.x) .. \",\" .. q3(v.y) .. \",\" .. q3(v.z)\n  end\n  local parts = {}\n  if type(ins) == \"table\" then\n    for i = 1, #ins do\n      local b = ins[i]\n      parts[#parts + 1] = \"I\" .. tostring(b.index) .. \"=\" .. b.value .. \"/\" .. b.label\n        .. \"/\" .. tostring(b.alignment)\n        .. \"/\" .. q3(b.width) .. \",\" .. q3(b.height) .. \",\" .. q3(b.font_size)\n        .. \"/\" .. xyz(b.position) .. \"/\" .. xyz(b.rotation) .. \"/\" .. xyz(b.scale)\n        .. \"/\" .. c4(b.color) .. \"/\" .. c4(b.font_color)\n    end\n  end\n  if type(dec) == \"table\" then\n    for i = 1, #dec do\n      local d = dec[i]\n      parts[#parts + 1] = \"D\" .. d.name .. \"=\" .. d.url\n        .. \"/\" .. xyz(d.position) .. \"/\" .. xyz(d.rotation) .. \"/\" .. xyz(d.scale)\n    end\n  end\n  e.ioSig = \"|IO\" .. table.concat(parts, \";\")\nend\n\n-- =========================\n-- NOTECARD TEXT  (2026-09-14)\n-- =========================\n-- The site drew a notecard's TITLE and nothing else, because the body -- which\n-- in TTS is the object's DESCRIPTION -- never rode the wire at all. The user, of\n-- the notecard on his table: it \"doesn't have all the notes\".\n--\n-- Read on the round-robin visit and NOWHERE else, exactly as the inputs and\n-- decals above are, and for the same reason: refreshButtonsEntry is the only\n-- function that writes e.buttons, so every path that fills BTN_CACHE funnels\n-- through it -- the round-robin visit, a TTL miss inside extractButtonsCached,\n-- and the \"Setting up...\" warm-up on a cold entry. Both item builders then read\n-- the cache back and make no engine call of their own.\n--\n-- NOTECARD ONLY, decided by tryGetKind -- the SAME answer the wire's `kind`\n-- carries, and a pcall'd read of obj.name with no serialisation behind it. So\n-- \"the item whose kind is Notecard\" and \"the object a description was read off\"\n-- can never disagree. Everything else leaves e.desc nil, which keeps the field\n-- off every other item on the wire entirely.\n--\n-- e.descSig is what makes an EDIT arrive. rrMetaSig folds it in as a pure\n-- BTN_CACHE lookup, which puts it on both signature paths (lightObjSig and the\n-- per-tick change gate) without one engine call landing on either: a rewritten\n-- card moves the gate on the first tick after its visit and the fragment is\n-- rebuilt. Same latency as a rename. The signature is only ever compared against\n-- itself, so the text goes in verbatim -- newlines and all -- which is the\n-- argument textSigFor and AUTO.stampIoSig above both make.\nAUTO.DESC_MAX = 1000\n\nAUTO.stampDesc = function(obj, e)\n  if tryGetKind(obj) ~= \"Notecard\" then\n    e.desc = nil\n    e.descSig = \"\"\n    return\n  end\n  local ok, d = pcall(function() return obj.getDescription() end)\n  local s = (ok and type(d) == \"string\") and d or \"\"\n  -- The card shows about ten lines, so the cap is what keeps a pasted essay out\n  -- of every payload the room sends. It counts BYTES -- Lua has no other kind of\n  -- string length -- but it never cuts a character in half. After the sub, the\n  -- cut walks back over UTF-8 continuation bytes (0x80-0xBF) to the lead byte of\n  -- the last character, reads the length that lead byte promises, and drops that\n  -- character whole when the cap landed inside it. So the result is at most 1000\n  -- bytes AND always valid UTF-8 -- never the half character the site used to\n  -- draw as a replacement glyph. Pure ASCII still cuts at exactly 1000; the\n  -- arithmetic is at most four byte() calls on a description over the cap, and\n  -- nothing at all on one under it.\n  if #s > AUTO.DESC_MAX then\n    s = s:sub(1, AUTO.DESC_MAX)\n    local i = #s\n    while i > 0 and s:byte(i) >= 0x80 and s:byte(i) < 0xC0 do i = i - 1 end\n    if i > 0 then\n      local b = s:byte(i)\n      local need = (b >= 0xF0 and 4) or (b >= 0xE0 and 3) or (b >= 0xC0 and 2) or 1\n      if #s - i + 1 < need then s = s:sub(1, i - 1) end\n    end\n  end\n  e.desc = s\n  e.descSig = \"|DS\" .. s\nend\n\n-- =========================\n-- HOT TICKS  (2026-09-07)\n-- =========================\n-- tts/lua/hot-set/README.md has the why. In short: the poll runs at 0.25 s, but\n-- reading the whole table -- above all the cheap signature, 14.3 ms of a\n-- measured 20.65 ms poll on a quiet 207-object table -- is far too expensive to\n-- run four times a second. On 2026-09-07 full and hot ticks alternated for that\n-- reason. Since the split scan (2026-09-12, the default since 2026-09-13) the\n-- table's scan is split over tick A and tick B instead: EVERY tick calls\n-- AUTO.hotStep(now, AUTO.HOT_TICKS), which samples and expires the objects in\n-- AUTO.hot, and both half-scans feed AUTO.noteSeen through the change gate.\n--\n-- Everything hangs off AUTO for the reason everything here does: the chunk is\n-- close to Lua's 200-locals-per-scope limit (a named rule in\n-- tts/build/rules.json pins the count).\n\n-- How long an object stays hot after the last movement seen. A second is\n-- comfortably more than the 0.5 s between two reads of an object's half, so\n-- something that keeps moving is never dropped and re-found, and something that\n-- stops is out of the set -- and free again -- within a second.\nAUTO.HOT_SECONDS = 1.0\n\n-- How close to a scheduled FULL a hot publish may still happen. pollLoop reads\n-- os.clock() to decide and buildDiffSnapshot reads it again a moment later; a\n-- second of margin makes it impossible for the second read to cross the boundary\n-- and turn a hot publish into a full one built from the hot filter. It costs one\n-- skipped hot tick every FULL_SNAPSHOT_SECONDS.\nAUTO.HOT_FULL_MARGIN = 1.0\n\n-- HOT TICKS, ON OR OFF (2026-09-09; the split scan, 2026-09-12). A plain switch\n-- for the hot SAMPLING, wired to a button on the panel, so the question \"is the\n-- 0.25 s sampling what I feel while dragging?\" can be answered by pressing it\n-- twice instead of argued about.\n--\n-- IN THIS BUILD there is no tick that can be skipped whole -- each one carries\n-- half the table's scan -- so the switch is the `sample` argument of the ONE\n-- AUTO.hotStep call every tick makes. ON, the default: an object that is moving\n-- is read four times a second, twice by its half-scan and twice here. OFF: the\n-- hot set is still EXPIRED on every tick, so an object that stopped moving still\n-- leaves it, but nothing is sampled -- movement then reaches the site from the\n-- half-scans alone, every 0.5 s, which is the cadence buttons, names, counters\n-- and everything else already run at.\n--\n-- WHAT IS NOT AFFECTED, deliberately: the half-scans themselves, and the\n-- one-shot FLIGHT post, which is armed by an event rather than by the poll and\n-- does its own hot publish. A flight that leaves a hot payload pending is\n-- carried by the NEXT tick's hot publish whatever this switch says -- since the\n-- split every tick makes one. pollLoop's third branch is the fallback for a\n-- payload that could not be SENT at all: nothing publishable, or nothing new to\n-- say once it was built.\n--\n-- NOT PERSISTED, and that is the point: it is a diagnostic switch, so a table\n-- saved with it off must not quietly ship a slower tool to the next session.\n-- Every load starts it true.\nAUTO.HOT_TICKS = true\n\n-- Called from the change gate (AUTO.spreadGate), once per object per half-scan\n-- -- so every object once every 0.5 s, on tick A or tick B -- with the gate's\n-- own `s` as it stands after its FIRST line -- position and rotation only, before\n-- the button / name / counter / quantity / shuffle folds are appended. That is\n-- deliberate and load-bearing: AUTO.hotStep rebuilds the same expression, so the\n-- two strings are comparable, and a label change does not make an object \"moving\".\n--\n-- Cost per object per half-scan: one hash read, one hash write, one compare. No\n-- TTS call is added -- `g`, `o` and `s` are all things the gate already had.\n--\n-- The FIRST sighting of a GUID only records it (prev == nil): without that, every\n-- object on the table would be marked hot on the first tick after a room create.\n--\n-- THE STAMP IT STORES IS AUTO.scanAt, not the current count: the reference it is\n-- handed came out of the scan arrays, which the identity pass captured at that\n-- count -- on this tick or on the one before it.\nAUTO.noteSeen = function(zg, g, o, s)\n  local prev = AUTO.hotSig[g]\n  AUTO.hotSig[g] = s\n  -- WHICH ZONE, whether or not it moved (2026-09-07). The block below only runs\n  -- when the object CHANGED position, so an entry created by an EVENT -- a token\n  -- pulled from a bag, say -- kept whatever zone AUTO.markHot could give it,\n  -- which is nothing at all (zg = nil), and AUTO.hotListFor skips an entry\n  -- without a zone. A token picked up, put down and then left alone therefore\n  -- stayed unpublishable for good. This walk is the authority on membership, so\n  -- it writes the zone in every time it disagrees, movement or no movement. One\n  -- hash read per object per half-scan and no engine call -- `zg` and `g` are\n  -- things the gate already had.\n  local e = AUTO.hot[g]\n  if e ~= nil and e.zg ~= zg then e.zg = zg end\n  if prev ~= nil and prev ~= s then\n    if e then\n      e.o = o; e.zg = zg; e.at = AUTO.scanAt\n      e[\"until\"] = os.clock() + AUTO.HOT_SECONDS\n    else\n      AUTO.hot[g] = { o = o, zg = zg, at = AUTO.scanAt,\n                      [\"until\"] = os.clock() + AUTO.HOT_SECONDS }\n    end\n  end\nend\n\n-- The event route into the hot set: onObjectPickUp / onObjectDrop /\n-- onObjectLeaveContainer / onObjectSpawn, all of which hand over a reference that\n-- is alive by definition. A token pulled out of a bag is therefore sampled on the\n-- very next tick instead of waiting for an full tick to notice it moved.\n--\n-- An entry that already exists KEEPS its zone: that is the zone the object was\n-- last seen in, which for a token going in and out of a bag is where it comes\n-- back. A brand-new entry has no zone yet (zg = nil) and is not published by a\n-- hot tick at all until the next full tick's noteSeen fills it in -- publishing an\n-- object into a guessed zone would be worse than publishing it 0.25 s later.\n--\n-- THE STAMP IS THE CURRENT COUNT, because the reference is one the EVENT just\n-- handed over: it is alive now, whatever died before now. That is what revives a\n-- token drawn out of a bag, with no clearing rule anywhere.\n--\n-- Cheap on purpose: these handlers fire for every object in the game.\nAUTO.markHot = function(o)\n  if not broadcasting then return end\n  if not o then return end\n  local okG, g = pcall(function() return o.getGUID() end)\n  if not okG or type(g) ~= \"string\" or g == \"\" then return end\n  local e = AUTO.hot[g]\n  if e then\n    e.o = o; e.at = AUTO.deathSeq\n    e[\"until\"] = os.clock() + AUTO.HOT_SECONDS\n  else\n    AUTO.hot[g] = { o = o, zg = nil, at = AUTO.deathSeq,\n                    [\"until\"] = os.clock() + AUTO.HOT_SECONDS }\n  end\nend\n\n-- =========================\n-- THE ONE DEAD RECORD  (2026-09-13)\n-- =========================\n-- A REFERENCE IS SAFE TO TOUCH IF IT WAS CAPTURED AFTER THE OBJECT LAST DIED.\n-- That is the whole rule. One record of death, one comparison, and every table\n-- that holds object references remembers the count it captured them at -- so no\n-- cache keeps a dead flag of its own, nothing has a clearing rule, and there is\n-- no fallback to a second answer.\n--\n--   deathSeq -- counts deaths. Bumped by AUTO.markDeadGuid and never reset: it is\n--               a clock, and a clock that ran backwards would call a dead reference\n--               live. A COUNTER rather than os.clock() on purpose: a token put back\n--               in its bag and drawn again in the same frame is a death and a fresh\n--               capture with no time between them, and the counter still orders them.\n--   deathAt  -- [guid] = the deathSeq at that object's most recent death.\n--   rrAt / scanAt -- the deathSeq at which the round-robin lists / the scan arrays\n--               captured their references. Hot entries, flight-pending records and\n--               the warm-up list carry their own `at` for the same reason.\n--\n-- WHY IT REPLACED THREE RECORDS. The round-robin's own set is filled by\n-- onObjectDestroy and cleared only by the 10 s rebuild; the hot set kept a flag\n-- and dropped its reference; and a third, three-valued set existed only to paper\n-- over the first one's clearing rule. TTS fires onObjectDestroy when an object\n-- goes INTO a container, so for up to ten seconds after a chaos token went back\n-- in its bag the round-robin's set still named it -- and the flight path read\n-- that set, which is why every redraw inside those ten seconds was taken for\n-- \"destroyed, nothing to animate\" and got no arc at all (seen in game\n-- 2026-09-12). With one record and a stamp per capture that class of bug has\n-- nowhere to live.\nAUTO.deadRef = function(g, at)\n  local d = AUTO.deathAt[g]\n  return d ~= nil and d > at\nend\n\n-- A death record is needed only while some cache still holds a reference captured\n-- BEFORE it. Once every capture stamp is at or past a death, no comparison can ever\n-- read it again, so it goes. Every table that holds object references must be in\n-- the minimum below; one that is not could see its record pruned from under it.\n--\n-- Called ONCE, from pollLoop's tick-A rebuild branch and right after\n-- rebuildRoundRobinLists(), so AUTO.rrAt is as fresh as it ever gets. NOT a\n-- clear-at-rebuild: a hot entry can hold a reference far older than 10 s (an\n-- object dragged for a minute keeps the reference its pick-up event handed over,\n-- its expiry pushed out by every sample), and a clear would call that reference\n-- live the moment the object died -- exactly the bug this replaced.\nAUTO.deathPrune = function()\n  local oldest = AUTO.rrAt\n  if AUTO.scanAt < oldest then oldest = AUTO.scanAt end\n  for _, e in pairs(AUTO.hot) do\n    if e.at ~= nil and e.at < oldest then oldest = e.at end\n  end\n  for _, p in pairs(AUTO.flightPending) do\n    if p.at ~= nil and p.at < oldest then oldest = p.at end\n  end\n  if AUTO.setup.active and AUTO.setup.at ~= nil and AUTO.setup.at < oldest then\n    oldest = AUTO.setup.at\n  end\n  -- Setting the key pairs() is currently on to nil is explicitly allowed.\n  for g, d in pairs(AUTO.deathAt) do\n    if d <= oldest then AUTO.deathAt[g] = nil end\n  end\nend\n\n-- The object is gone or has gone into a container: its reference must never be\n-- read again, because reading a dead one raises an uncatchable .NET error that no\n-- pcall can see. So the death is COUNTED, and this GUID's most recent death is\n-- recorded at that count. Nothing is flagged and no reference is cleared -- the\n-- hot entry is left exactly as it is, and the spawn or leave-container event that\n-- brings the object back writes a fresh reference AND a fresh stamp over it,\n-- which is the whole of what makes it live again.\n--\n-- The stored movement string goes with the death, so a returning object is not\n-- compared against where it used to be.\nAUTO.markDeadGuid = function(g)\n  -- ONE RECORD, ONE COMPARISON (Spectator Tool Autodraw build only,\n  -- 2026-09-13). The counter goes up and this GUID's most recent death is\n  -- recorded at it. Every reference captured at a lower count is dead from\n  -- here on, and AUTO.deadRef is the only test anything makes -- see THE ONE\n  -- DEAD RECORD above.\n  AUTO.deathSeq = AUTO.deathSeq + 1\n  AUTO.deathAt[g] = AUTO.deathSeq\n  AUTO.hotSig[g] = nil\n  -- ... and THE BIG DECK ROSTER (2026-09-17), which is keyed on this same\n  -- guid. The deck was destroyed, merged away or put into a bag; a deck that\n  -- comes back is learned again on its next fragment build, for one getData.\n  AUTO.deckRoster[g] = nil\n  -- GHOST PROTECTION (2026-09-08). A FLIGHT post can add an object to the board\n  -- that the whole-table walk has never seen, and only that walk emits a\n  -- `remove`. So a death owes the server one ORDINARY diff: this flag holds the\n  -- publish gate open until one goes out, whatever the cheap signature says.\n  -- Raised unconditionally -- an object with no hot entry can perfectly well\n  -- have been published by a flight post moments earlier.\n  AUTO.fullPending = true\nend\n\nAUTO.markDead = function(o)\n  if not o then return end\n  local okG, g = pcall(function() return o.getGUID() end)\n  if okG and type(g) == \"string\" and g ~= \"\" then AUTO.markDeadGuid(g) end\nend\n\n-- One pass over the hot set. pollLoop calls this on EVERY tick (since the split\n-- scan, 2026-09-12/13) with AUTO.HOT_TICKS as `sample`: false (the Hot ticks\n-- button OFF) only expires what has stopped moving; true also reads each live\n-- entry's position and rotation.\n--\n-- The mini-signature below is character for character the change gate's first\n-- line (the named rule `identical` in tts/build/rules.json pins the two copies),\n-- which is what makes AUTO.hotSig comparable between the gate and this pass.\n-- It carries ALL THREE rotation axes since 2026-09-08: a card FLIP turns\n-- rotation z by 180 degrees without moving the card, so with the yaw alone a\n-- flip that happened between two samples left the string untouched.\n--\n-- Setting t[k] = nil for the key the loop is CURRENTLY on is explicitly allowed\n-- during a pairs() traversal; nothing else here touches another key.\nAUTO.hotStep = function(now, sample)\n  local hot = AUTO.hot\n  for g, e in pairs(hot) do\n    if (e[\"until\"] or 0) <= now then\n      -- Stopped moving -- or was marked dead and no event brought it back.\n      hot[g] = nil\n    elseif sample and e.o and not AUTO.deadRef(g, e.at) and not AUTO.excl[g] then\n      local o = e.o\n      local ok, m = pcall(function()\n        local p = o.getPosition()\n        local r = o.getRotation()\n        return g .. \"@\" .. q(p.x) .. \",\" .. q(p.y) .. \",\" .. q(p.z) .. \":\" .. q(r.x) .. \",\" .. q(r.y) .. \",\" .. q(r.z)\n      end)\n      if ok and type(m) == \"string\" and m ~= AUTO.hotSig[g] then\n        AUTO.hotSig[g] = m\n        e[\"until\"] = now + AUTO.HOT_SECONDS\n        AUTO.hotPending = true\n      end\n    end\n  end\nend\n\n-- What AUTO.filtered answers with while a hot publish is being built: the live,\n-- non-excluded hot objects this zone owns. An entry whose zone is still unknown\n-- is left out -- see AUTO.markHot.\nAUTO.hotListFor = function(z)\n  local out = {}\n  local okG, zg = pcall(function() return z.getGUID() end)\n  if not okG or type(zg) ~= \"string\" or zg == \"\" then return out end\n  for g, e in pairs(AUTO.hot) do\n    if e.zg == zg and e.o and not AUTO.deadRef(g, e.at) and not AUTO.excl[g] then\n      out[#out + 1] = e.o\n    end\n  end\n  return out\nend\n\n-- AUTO.hotSig gains a key for every object the gate walks, so left alone it would\n-- keep the GUID of everything that has ever been on the table. The 10 s\n-- round-robin rebuild throws it away and keeps only what is actually moving.\n--\n-- The cost is one tick of detection for an object that starts moving in the very\n-- tick of a rebuild (its first sighting only records it again) -- and that tick's\n-- own cheap signature publishes the movement anyway, so nothing is lost but the\n-- intermediate hot sample.\nAUTO.hotPrune = function()\n  local keep = {}\n  for g, _ in pairs(AUTO.hot) do\n    local sg = AUTO.hotSig[g]\n    if sg ~= nil then keep[g] = sg end\n  end\n  AUTO.hotSig = keep\nend\n\n-- =========================\n-- THE ORDINARY DIFF'S RE-READ GATE  (2026-09-08)\n-- =========================\n-- The cheap signature scan reads every object's GUID, position and rotation and\n-- folds in its buttons, its rr metadata, a container's quantity and its shuffle\n-- epoch -- and then buildDiffSnapshot used to read every one of those objects\n-- AGAIN, through lightObjSig, purely to ask whether it had changed. On this\n-- table that was 62-107 ms for a diff carrying one moved token.\n--\n-- So the scan's finished per-object string is kept (AUTO.gateSig, written by\n-- AUTO.spreadGate once the object's gate string is complete) and the\n-- diff compares it against the string that object's light signature was last\n-- computed under (AUTO.diffGate). Equal means nothing the scan can see has\n-- moved, so the previous signature is reused and the object is not touched.\n--\n-- HOW MANY OBJECTS AN ORDINARY DIFF STILL READS FOR REAL. The light signature\n-- carries THREE things the scan string does not -- the counter value, the colour\n-- tint, and movement finer than POS_EPS -- so a rotating window of LAP_PER_DIFF\n-- objects is read whatever the gate says, and the window moves on by that much\n-- every diff. (Face-down was a fourth until the mini-signature took all three\n-- rotation axes on 2026-09-08: a flip turns rotation z, so the scan string moves\n-- and the gate forces the re-read itself.) Every object is re-read at least once every\n-- ceil(n / LAP_PER_DIFF) ordinary diffs; on a 202-object table that is 17\n-- diffs, about 8.5 s at the full tick's cadence. The window itself is two\n-- integer comparisons inside the walk, not a lookup, so it costs nothing per\n-- object.\n--\n-- HALVED, 24 to 12, on 2026-09-08: the re-read was about 5 ms of a diff that\n-- the same day's other change had brought down to roughly 20 ms, so it had\n-- become a quarter of what was left. What it buys back is latency on the\n-- three things only the lap can see -- a counter value, a colour tint, and\n-- movement finer than 0.03 units -- which now take up to 8.5 s rather than\n-- 4.5 s to self-heal on a 202-object table. Nothing a player DOES falls in\n-- that set: a move, a flip, a rename, a button, an input, a decal, a\n-- container's count and a shuffle are all in the scan string and publish on\n-- the very next diff.\nAUTO.LAP_PER_DIFF = 12\n\n-- Both maps would otherwise keep a key for every GUID the gate has ever walked,\n-- so they are thrown away on the 10 s round-robin rebuild -- the one periodic\n-- sweep this file already has -- and rebuilt from the GUIDs the diff baseline\n-- still holds. LAST_ZONE_STATE is exactly the set the diff cares about: an\n-- object it does not know is a new object, which is re-read anyway.\n--\n-- Pruning is SAFE at any moment, which is why it needs no care about timing: a\n-- missing gateSig or a missing diffGate entry both fail the reuse test, so the\n-- worst a prune can do is make the next diff read an object for real.\n-- The scan's own per-zone lists (AUTO.scanG / scanO / scanN) are swept in the\n-- same pass and against the same set: a zone LAST_ZONE_STATE no longer knows is\n-- a zone the diff will never ask about again, and its arrays would otherwise\n-- hold a reference to every object that was in it for the rest of the session.\n-- The per-object entries need no sweep at all -- they are overwritten in place\n-- every tick and read only up to the count the scan just wrote.\n--\n-- This runs BEFORE the cheap signature in the same tick (see pollLoop), so a\n-- zone dropped here is recorded again moments later and the diff never notices.\nAUTO.gatePrune = function()\n  local keepG, keepD = {}, {}\n  local keepSG, keepSO, keepSN = {}, {}, {}\n  local keepGen, keepZO = {}, {}\n  for zg, zs in pairs(LAST_ZONE_STATE) do\n    if AUTO.scanG[zg] ~= nil then keepSG[zg] = AUTO.scanG[zg] end\n    if AUTO.scanO[zg] ~= nil then keepSO[zg] = AUTO.scanO[zg] end\n    if AUTO.scanN[zg] ~= nil then keepSN[zg] = AUTO.scanN[zg] end\n    if AUTO.scanGen[zg] ~= nil then keepGen[zg] = AUTO.scanGen[zg] end\n    if AUTO.zoneOrder[zg] ~= nil then keepZO[zg] = AUTO.zoneOrder[zg] end\n    local objs = zs.objs\n    if type(objs) == \"table\" then\n      for g, _ in pairs(objs) do\n        local s = AUTO.gateSig[g]\n        if s ~= nil then keepG[g] = s end\n        local d = AUTO.diffGate[g]\n        if d ~= nil then keepD[g] = d end\n      end\n    end\n  end\n  AUTO.gateSig = keepG\n  AUTO.diffGate = keepD\n  AUTO.scanG = keepSG\n  AUTO.scanO = keepSO\n  AUTO.scanN = keepSN\n  AUTO.scanGen = keepGen\n  AUTO.zoneOrder = keepZO\nend\n\n-- COULD a hot publish built RIGHT NOW carry anything at all? AUTO.hotListFor\n-- answers per zone with the live, un-excluded hot objects that KNOW their zone,\n-- so an entry with zg = nil -- which is every new entry on a table of hand-drawn\n-- zones, until a half-scan fills the zone in -- contributes nothing. Without\n-- this test the tick would BUILD a diff to find that out: a whole payload, a\n-- seq and a post, every 0.5 s for as long as the entry stayed hot. The caller\n-- clears AUTO.hotPending instead: the movement is not lost, because the next\n-- tick B's signature sees it 0.25 s later like any other.\n--\n-- It is the CHEAP PRE-CHECK, and it answers COULD, not WOULD: the objects it\n-- finds may still turn out to have nothing new to say once the diff has walked\n-- them (the sample landed on the same rounded pose). That payload is built, and\n-- then not posted -- see the EMPTY DIFFS section of the module docstring and the\n-- skip at the top of publishIfNeeded, which has covered hot payloads since\n-- 2026-09-13. This test exists to make the common case cost one walk of a\n-- handful of entries instead of a whole diff.\n--\n-- One walk of AUTO.hot, which holds what is moving right now -- a handful of\n-- objects, not the table.\nAUTO.hotPublishable = function()\n  for g, e in pairs(AUTO.hot) do\n    if e.o and not AUTO.deadRef(g, e.at) and not AUTO.excl[g]\n       and type(e.zg) == \"string\" and e.zg ~= \"\" then\n      return true\n    end\n  end\n  return false\nend\n\n-- THE SAME OBJECT SIGNATURE WITH THE POSE TAKEN OUT (2026-09-07).\n-- lightObjSig is `g|tag|hs|P<pos>|R<rot>|fd|counter|tint|buttons|rrMeta|Q|epoch`,\n-- and the FOURTH and FIFTH fields -- and only those two -- are where the object\n-- is. Everything else is what it IS: quantity, face-down, shuffle epoch, tint,\n-- buttons, name, inputs, decals.\n--\n-- WHAT IT IS FOR. encodedZoneItemFor invalidates a container's cached peek every\n-- time its signature changes, and re-reading a peek means obj.getData() -- 196 ms\n-- for this table's 2330-card bag. A bag that is merely being CARRIED changes its\n-- signature on every hot tick, and none of those changes can possibly have\n-- altered its contents. Comparing the pose-free part is what tells the two\n-- apart. There is a build guard that P and R really are fields 4 and 5, so this\n-- pattern cannot silently start eating the wrong ones.\nAUTO.sigSansPose = function(sig)\n  if type(sig) ~= \"string\" then return \"\" end\n  -- The parentheses matter: gsub returns a count as well, and this must answer\n  -- with one value.\n  return (sig:gsub(\"|P[^|]*|R[^|]*\", \"\", 1))\nend\n\n-- =========================\n-- BIG CONTAINER CAP  (2026-09-08)\n-- =========================\n-- obj.getData() serialises the WHOLE container -- every contained object -- and\n-- the setup measurement put this table's 2330-card bag at 171 ms and its\n-- 396-card deck at 141 ms. Above the limit the tool stops asking for that\n-- serialisation at all.\n--\n-- WHAT IT COSTS: such a container's peek is NAMES ONLY. No card art, no deck\n-- preview sprite, no DeckIDs. Every caller already handles a nil data --\n-- containerPeekRaw falls back to getObjects(), deckPeekHybrid and\n-- deckPeekFromDeckIDs decline, deckPreviewSprite answers nil and the object\n-- falls back to trySingleImage -- so the only new thing is `capped = true` on\n-- the peek, which lets the site say WHY there is no art.\n--\n-- THE RULE IN ONE LINE, as it stands since 2026-09-17: never per tick, and\n-- never for the peek. ONE read per big DECK per change burst, deferred to a\n-- quiet tick and never twice inside 2 s -- THE BIG DECK ROSTER below, the single\n-- deliberate exception, which is made OUTSIDE tryGetData so this choke point\n-- still declines for every other caller. The peek is unchanged: `capped` still\n-- means the CONTENTS are names only, and the roster publishes the top card\n-- alone, never the list.\nAUTO.BIG_CONTAINER_LIMIT = 250\n\n-- A SECOND, MUCH HIGHER CEILING (2026-09-08): above this many items the\n-- container is not LISTED either. obj.getObjects() on this table's 2330-card\n-- bag builds a table of 2330 entries every time its peek is invalidated, and\n-- the peek keeps the first 80 of them. Past the limit the peek is the count and\n-- nothing else -- `capped = true, source = \"count\"` -- and deckPreviewSprite\n-- answers nil, so the object falls back to its single image.\n--\n-- READ THIS TOGETHER WITH THE HISTORY. A cap at 200 items was tried on\n-- 2026-09-06 and removed the next day, because the user wants huge bags LISTED.\n-- That decision stands up to a thousand items; this is the point past which\n-- listing stops being affordable at all. It is the ONE number to change if the\n-- 2330-card bag should be listed again.\nAUTO.HUGE_CONTAINER_LIMIT = 1000\n\n-- HOW MANY ITEMS, read ONCE PER FRAGMENT BUILD. Both caps above are a\n-- comparison against this one number, so the two of them, and the peek's own\n-- `capped` marker, share a single getQuantity inside a build. -1 for anything\n-- that is not a container, which is what makes both caps answer false for one.\n--\n-- The memo encodedZoneItemFor opens carries the answer. Outside a build there is\n-- no memo and the quantity is read once per call: a cheap engine property, the\n-- same one the per-tick change gate already reads for every container on the\n-- table.\n--\n-- The GUID test is the same one tryGetData makes, and for the same reason: the\n-- memo answers for exactly one object, and a contained object or the next object\n-- in the walk must go to the engine.\nAUTO.containerQty = function(obj, memo)\n  local tag = \"\"\n  local okT, t = pcall(function() return obj.tag end)\n  if okT and type(t) == \"string\" then tag = t end\n  if tag ~= \"Bag\" and tag ~= \"Deck\" and tag ~= \"Infinite\" then return -1 end\n  if memo ~= nil then\n    local okG, mg = pcall(function() return obj.getGUID() end)\n    if not okG or mg ~= memo.guid then memo = nil end\n  end\n  if memo ~= nil and memo.qtyRead then return memo.qty end\n  local qty = tryGetQuantity(obj)\n  if memo ~= nil then memo.qtyRead = true; memo.qty = qty end\n  return qty\nend\n\n-- Is this a container too big to SERIALISE? Asked by tryGetData before it reads.\nAUTO.bigContainer = function(obj, memo)\n  return AUTO.containerQty(obj, memo) > AUTO.BIG_CONTAINER_LIMIT\nend\n\n-- Is it too big to LIST? Asked by safeGetContainerObjects before it reads, and\n-- by containerPeekRaw, which answers with the count alone when it is.\nAUTO.hugeContainer = function(obj, memo)\n  return AUTO.containerQty(obj, memo) > AUTO.HUGE_CONTAINER_LIMIT\nend\n\n-- =========================\n-- THE BACK OF A BIG FACE-DOWN DECK  (2026-09-17)\n-- =========================\n-- A Deck over AUTO.BIG_CONTAINER_LIMIT items is never serialised (above), and\n-- obj.getData() was the ONLY source of its CustomDeck sheet URLs -- so\n-- deckPreviewSprite answers nil for it and the site draws its placeholder where\n-- the deck is. The user's 396-card deck lies FACE DOWN, and a face-down deck\n-- shows nothing but its BACK, which needs no card id at all.\n--\n-- obj.getCustomObject() carries that back, for 534 us measured on that very deck\n-- on 2026-09-17 -- against 28.4 ms for getData() on the same one. It answers\n-- with a LIST, one record per CustomDeck sheet (keys 1..10 on that deck), and\n-- each record carries back / face / unique_back / width / height / number /\n-- sideways / back_is_hidden / type. All ten of that deck's sheets gave ONE\n-- distinct back URL and every one of them said unique_back false.\n--\n-- A record is USABLE when `back` is a non-empty string and `unique_back` is not\n-- true. A unique back is a sprite SHEET, and picking the right cell out of it\n-- needs the top card's index, which no cheap read on a capped deck gives -- so\n-- such a deck keeps today's placeholder rather than showing another card's back.\n--\n-- The table ITSELF is tried first (a flat record, which is what getCustomObject\n-- answers for a single-sheet object), then its entries in ipairs order, then\n-- anything else it holds; the first usable one wins.\n--\n-- IF A DECK EVER MIXES BACKS the spectator sees one of them, and not necessarily\n-- the top card's -- TTS itself shows the top card's own back. That is the whole\n-- of what this trades away, and there is nothing to see on a deck built from one\n-- sheet, or from sheets that share a back, which is every deck met so far.\n--\n-- COST: one pcall'd engine read, on the FRAGMENT BUILD path, and only for a deck\n-- that is face down, over the limit, and has no preview already. Nothing is\n-- added to lightObjSig, to either half-scan or to anything else per tick.\nAUTO.bigDeckBack = function(obj)\n  local ok, c = pcall(function() return obj.getCustomObject() end)\n  if not ok or type(c) ~= \"table\" then return nil end\n  -- ONE record -> the back it can publish, or nil. A `back` that is missing,\n  -- empty, or not a string at all (a malformed record) is simply not a back.\n  local function backOf(rec)\n    if type(rec) ~= \"table\" then return nil end\n    if rec.unique_back == true then return nil end\n    local b = rec.back\n    if type(b) ~= \"string\" or b == \"\" then return nil end\n    return { url = normalizeHttps(b), isBack = true }\n  end\n  local hit = backOf(c)\n  if hit then return hit end\n  local n = 0\n  for i, rec in ipairs(c) do\n    n = i\n    hit = backOf(rec)\n    if hit then return hit end\n  end\n  -- Whatever the array part did not cover: a records table keyed by the SHEET\n  -- NUMBER (2263, 3152, ... -- the way CustomDeck itself is keyed) would land\n  -- here rather than in the walk above.\n  for k, rec in pairs(c) do\n    if type(k) ~= \"number\" or k < 1 or k > n or k % 1 ~= 0 then\n      hit = backOf(rec)\n      if hit then return hit end\n    end\n  end\n  return nil\nend\n\n-- =========================\n-- WHICH ZONE IS THIS POINT IN  (2026-09-17)\n-- =========================\n-- WHAT IT FIXES. A hot post only carries objects whose hot entry KNOWS its zone,\n-- and the two event-driven paths that create an entry -- the FLIGHT post and the\n-- big deck roster's nudge -- cannot know one: AUTO.markHot has only a reference,\n-- and the walk that would attach a zone is a tick away. Both used to fall back on\n-- AUTO.zoneGuid, the zone THIS BUILD drew, which is nil on any table where the\n-- user drew their own. The user's table is one: its only zone is\n-- \"SpectatorTool:Area\", so AUTO.zoneGuid was nil and every chaos-token flight was\n-- dropped before it reached the wire -- confirmed from the live room's state on\n-- 2026-09-17, after the socket recording showed a plain hot `add` and no `flight`\n-- key on every drawn token.\n--\n-- So the zone is looked up by POSITION instead, which needs no walk of anything\n-- the tool owns and no tick.\n--\n-- THE SHORTCUT FIRST, because it is the common table: with exactly ONE live zone\n-- there is nothing to decide -- anything published at all is published in it -- so\n-- it answers without reading a single coordinate.\n--\n-- THE GEOMETRY, for a table with several. A ScriptingTrigger's SCALE IS ITS WORLD\n-- SIZE, so the box is centre +/- scale/2, turned by the zone's yaw. The point is\n-- moved into the zone's own frame -- (p - centre) rotated by -yaw about Y, Unity's\n-- left-handed convention -- and tested against the half-extents.\n--\n-- NOT positionToLocal, deliberately: whether it divides by the scale is not\n-- proven in this repo (the 3DText splice relies on it doing so, which is a\n-- DIFFERENT claim about a different call), and a wrong answer here is a silently\n-- undelivered flight. Arithmetic this build can check in a harness is worth more\n-- than an engine call it cannot.\n--\n-- TWO MARGINS, both deliberate. 10% horizontally, because a flight DESTINATION is\n-- often a hair outside a zone drawn tight around the play area -- a token dealt to\n-- the very edge of a mat -- and being one unit out must not cost the animation.\n-- And 2 world units of vertical slack either way, because zone heights vary\n-- wildly and an object in the air over a zone is in it for every purpose this\n-- answer is used for. Both only ever ADD objects to a zone the site already\n-- draws; neither can move an object between two zones the walk disagrees about,\n-- because the walk overwrites this answer on its next pass (AUTO.noteSeen).\n--\n-- COST: on the EVENT path only -- one flight frame, one draw off a big deck --\n-- never per tick and never per object. One cached zone list (the same one the tick\n-- reads), then one getPosition, getRotation and getScale per zone until a hit.\n-- Every engine read is pcall'd and a zone that throws is simply not the answer.\nAUTO.zoneAt = function(p)\n  if type(p) ~= \"table\" then return nil end\n  local px, py, pz = tonumber(p.x), tonumber(p.y), tonumber(p.z)\n  if px == nil or py == nil or pz == nil then return nil end\n  local okZ, zones = pcall(function() return refreshZoneCacheIfNeeded(false) end)\n  if not okZ or type(zones) ~= \"table\" then return nil end\n  -- The live ones, once: zoneAlive is the same test every other consumer makes,\n  -- and a dead reference must not be read for its box.\n  local live, n = {}, 0\n  for _, z in ipairs(zones) do\n    local okA, alive = pcall(function() return zoneAlive(z) end)\n    if okA and alive then n = n + 1; live[n] = z end\n  end\n  if n == 0 then return nil end\n  if n == 1 then\n    -- ONE ZONE: whatever is published is published in it.\n    local okG, g = pcall(function() return live[1].getGUID() end)\n    if okG and type(g) == \"string\" and g ~= \"\" then return g end\n    return nil\n  end\n  for i = 1, n do\n    local z = live[i]\n    -- ONE pcall around the whole box test: three reads on a reference that may\n    -- have died since the cache was filled, and the answer is a guid or nothing.\n    local ok, hit = pcall(function()\n      local c = z.getPosition()\n      local r = z.getRotation()\n      local sc = z.getScale()\n      if type(c) ~= \"table\" or type(sc) ~= \"table\" then return nil end\n      local hx = (tonumber(sc.x) or 0) * 0.5 * 1.10\n      local hz = (tonumber(sc.z) or 0) * 0.5 * 1.10\n      local hy = (tonumber(sc.y) or 0) * 0.5 + 2\n      local dx = px - (tonumber(c.x) or 0)\n      local dy = py - (tonumber(c.y) or 0)\n      local dz = pz - (tonumber(c.z) or 0)\n      -- WORLD -> LOCAL about Y, by -yaw. Unity turns local to world with\n      -- x' = x*cos + z*sin, z' = -x*sin + z*cos, so this is its inverse. The\n      -- height is untouched: a yaw cannot tilt the box.\n      local yaw = math.rad(tonumber(r and r.y) or 0)\n      local cs, sn = math.cos(yaw), math.sin(yaw)\n      local lx = dx * cs - dz * sn\n      local lz = dx * sn + dz * cs\n      if lx < -hx or lx > hx then return nil end\n      if lz < -hz or lz > hz then return nil end\n      if dy < -hy or dy > hy then return nil end\n      return z.getGUID()\n    end)\n    if ok and type(hit) == \"string\" and hit ~= \"\" then return hit end\n  end\n  return nil\nend\n\n-- =========================\n-- RE-PUBLISH AN OBJECT NOTHING HAS MOVED  (2026-09-17)\n-- =========================\n-- The ordinary diff emits an object only when its LIGHT SIGNATURE differs from\n-- the baseline's, and lightObjSig carries where the object is and what the tool\n-- can measure about it -- pose, facing, counter, tint, quantity. So a change to\n-- what an object LOOKS like that moves none of those is invisible to the whole\n-- publish machinery: the cheap signature does not move, so no tick decides to\n-- publish; the light signature does not move, so the walk takes its `unchanged`\n-- branch, which never calls the encoder at all. Clearing the cached fragment on\n-- its own therefore changes nothing.\n--\n-- This is the four things it takes to say \"send this object again anyway\":\n--   * THE DIFF BASELINE's entry for the object, dropped. That is what turns the\n--     next ordinary diff's verdict from `unchanged` into `add`, and it is the\n--     one of the four without which the other three do nothing.\n--     js/applyDiff.js upserts an add by guid exactly as it upserts an update,\n--     so an add for an object the site already has is a redraw and no more.\n--   * FRAG_CACHE -- the cached JSON is keyed on the light signature, which has\n--     not changed, so without this the add would carry the OLD fragment.\n--   * AUTO.diffGate -- belt and braces. With no baseline entry the gate's reuse\n--     test already fails on `oldSig ~= nil`; clearing the stamp says plainly\n--     that what it was computed under no longer describes this object.\n--   * AUTO.fullPending -- pollLoop's third branch publishes on it although the\n--     cheap signature has not moved, and publishIfNeeded's gate opens on it. It\n--     is settled by the first ORDINARY diff that goes out, which is this one.\n--\n-- WHO CALLS IT: THE BIG DECK ROSTER, after a read (AUTO.rosterRead), and that\n-- is the only site. The two others that look related are NOT the same job and\n-- are deliberately left alone:\n--\n--   * THE DEATH PATH (AUTO.markDeadGuid) raises AUTO.fullPending and nothing\n--     else, ON PURPOSE. What a death owes the server is a `remove`, and the\n--     remove sweep finds its work by walking the BASELINE for GUIDs the diff no\n--     longer saw -- so dropping the baseline entry here, which is what this\n--     function does, would delete the very record the sweep reads and the\n--     object would stay on the board for good. That is the ghost bug, inverted.\n--   * THE FLIGHT SPLICE (AUTO.flightSplice) edits the JSON of a fragment on its\n--     way into a post that the object's OWN MOVEMENT has already triggered. It\n--     publishes nothing, invalidates nothing and waits for nothing; it is a\n--     field added to a payload, not a reason to build one.\n--\n-- COST: one hash delete in each of two tables, one walk of the zone baselines\n-- (a handful of zones), and one flag. Called at most once per big deck per\n-- change burst.\nAUTO.forceEmit = function(g)\n  FRAG_CACHE[g] = nil\n  AUTO.diffGate[g] = nil\n  for _, zs in pairs(LAST_ZONE_STATE) do\n    if type(zs) == \"table\" and type(zs.objs) == \"table\" then zs.objs[g] = nil end\n  end\n  AUTO.fullPending = true\nend\n\n-- =========================\n-- THE BIG DECK ROSTER  (2026-09-17)\n-- =========================\n-- WHAT IT IS FOR. The block above publishes a big FACE-DOWN deck's back, which\n-- needs no card id at all. A face-UP one needs the top card's NUMBER, and the\n-- only source of card numbers is obj.getData() -- 22.9 to 26.0 ms on the user's\n-- 399-card deck, measured on the real table on 2026-09-17\n-- (artifacts/deck-probe/topcard-design.md). That is precisely the read the BIG\n-- CONTAINER CAP above forbids.\n--\n-- So the read is made ONCE PER BIG DECK PER CHANGE BURST and nowhere near a\n-- tick: a per-deck ROSTER of card numbers, filled by one deliberate getData\n-- that goes STRAIGHT to the engine -- never through tryGetData, which is the\n-- choke point the cap lives at and declines for exactly this object -- deferred\n-- to a quiet tick and never repeated inside AUTO.ROSTER_MIN_GAP. Every fragment\n-- build after that is a table lookup and the base's own\n-- tryCustomDeckImagesByCardIDFromCustomDeck, so the deck shows the card that is\n-- really on top, face up as well as face down.\n--\n-- POSITIONS ONLY, and that is the user's decision after three runs on the real\n-- table. 383 of that deck's 399 contained cards have an EMPTY guid, in\n-- getObjects() and in the serialisation alike, and unnamed decks and decks of\n-- one repeated name both exist -- so neither a guid key nor a name key can\n-- identify a card. What IS reliable, and is what this rests on: getObjects(),\n-- getData().DeckIDs and getData().ContainedObjects are ONE order, card for card\n-- (0 mismatches of 399); a FLIP does not reorder the list, it only turns the\n-- deck; and the top card is DeckIDs[1] face down and DeckIDs[#DeckIDs] face up,\n-- which is the end rule deckPreviewSprite has always used.\n--\n-- HOW IT STAYS RIGHT BETWEEN READS. Everything that can change the top card\n-- already moves the deck's signature -- the quantity (Q), the facing (the pose,\n-- all three rotation axes) and the shuffle counter (E, bumped by\n-- onObjectRandomize) -- so the fragment is rebuilt anyway. The only question is\n-- whether the roster's end entry is still the card that is there, and there are\n-- two answers, the second of which covers the first:\n--   * THE POP. onObjectLeaveContainer fires with the card's data INTACT -- a\n--     single-card getData was 109-147 us on that table, inside this very event --\n--     and dealing and drawing both take from the top. If the number the card\n--     gives is the one at the end the deck is facing, that end is removed and\n--     the roster is right again without reading the DECK at all. The number is\n--     asked for both ways round: obj.getCardID() first (a guess: nothing in this\n--     repo has seen it answer) and then getData().CardID, which is the one route\n--     the probe measured. See AUTO.rosterLeave.\n--   * THE DEFERRED RE-READ. Every leave and every enter marks the record DIRTY\n--     whatever the pop did, and AUTO.rosterService makes ONE real read, for ONE\n--     deck, per full tick. So a card taken from the MIDDLE -- the search\n--     window's drag-out, which fires the same event -- leaves the roster wrong\n--     for at most AUTO.ROSTER_CAP_WAIT plus a tick, and never for longer.\n--\n-- WHAT IT COSTS. Nothing per object and nothing per tick. AUTO.rosterService\n-- walks a table with one entry per BIG deck -- none at all on most tables -- and\n-- returns on the first field read when nothing is dirty. The read itself is one\n-- getData on tick A, the tick that never publishes. Memory is one array of\n-- numbers per big deck plus a reference to that deck's CustomDeck sheet table,\n-- both dropped with the record.\n\n-- HOW LONG A DIRTY RECORD WAITS. MIN_GAP is the floor between two reads of the\n-- SAME deck, so a stream of draws cannot become a stream of 25 ms reads;\n-- CAP_WAIT is how long a dirty record waits for a QUIET tick before it is read\n-- anyway. The user's own measure of the cost: 25-30 ms is imperceptible on his\n-- table, so waiting for quiet is a courtesy and the cap is what makes it\n-- bounded.\nAUTO.ROSTER_MIN_GAP = 2.0\nAUTO.ROSTER_CAP_WAIT = 3.0\n\n-- The record for a CONTAINER, and the GUID it was found under. This is the\n-- whole of what the event hooks pay on a table with no big deck on it: one\n-- pcall'd getGUID and one failed hash lookup. Bags and decks fire those events\n-- constantly, so nothing else may happen before this has answered.\n--\n-- The lookup IS the \"is it a deck with a roster\" test: a record is only ever\n-- created by AUTO.rosterTop, which is reached from the Deck branch of the\n-- fragment builder and from nowhere else.\n--\n-- It answers with the GUID as well so a caller that needs it -- AUTO.rosterLeave,\n-- which nudges the deck onto the hot cadence -- does not read it a second time.\n-- A caller that does not want it simply takes the first value, which is what\n-- AUTO.rosterTouch does.\nAUTO.rosterFor = function(container)\n  if container == nil then return nil end\n  local ok, g = pcall(function() return container.getGUID() end)\n  if not ok or type(g) ~= \"string\" or g == \"\" then return nil end\n  return AUTO.deckRoster[g], g\nend\n\n-- THE TOP CARD, out of the roster alone. Called from the Deck branch of\n-- itemForObject for a deck over the cap, and it makes ONE engine call -- the\n-- pcall'd getGUID that keys the record. No getData, no getObjects, no\n-- getCustomObject.\n--\n-- (The fragment build's memo carries this same guid, but confirming the memo is\n-- THIS object's costs exactly the read it would save -- tryGetData and\n-- safeGetContainerObjects both pay it -- so there is nothing to save here. The\n-- memo exists to spare the whole-object serialisation and the contents list,\n-- neither of which this function ever asks for.)\nAUTO.rosterTop = function(obj, faceDown)\n  local okG, g = pcall(function() return obj.getGUID() end)\n  if not okG or type(g) ~= \"string\" or g == \"\" then return nil end\n  -- TOO BIG TO LIST IS TOO BIG TO SERIALISE, EVER (2026-09-17). Above\n  -- AUTO.HUGE_CONTAINER_LIMIT the peek is a bare count, and getData on such a\n  -- container is the 171 ms the caps exist to avoid -- once per change burst is\n  -- still 171 ms. No roster is kept for one, and a record such a deck somehow\n  -- has is dropped here rather than left for AUTO.rosterService to act on. The\n  -- quantity rides AUTO.dataMemo, open around this whole fragment build, so\n  -- this is a table lookup and not an engine call.\n  if AUTO.hugeContainer(obj, AUTO.dataMemo) then\n    AUTO.deckRoster[g] = nil\n    return nil\n  end\n  local r = AUTO.deckRoster[g]\n  if r == nil then\n    -- FIRST SIGHTING: ask for a read, answer nil. The caller falls back to what\n    -- this build did before -- the shared back face down, the placeholder face\n    -- up -- for at most ROSTER_CAP_WAIT plus a tick.\n    AUTO.deckRoster[g] = { ids = nil, cd = nil, dirty = true,\n                           dirtyAt = os.clock(), readAt = 0, n = 0 }\n    return nil\n  end\n  -- A DIRTY RECORD STILL ANSWERS, with the numbers it has. They are stale for\n  -- at most one change burst, and the card that was on top a moment ago is a\n  -- better answer than a checkerboard.\n  local ids = r.ids\n  local n = r.n or 0\n  if type(ids) ~= \"table\" or n <= 0 then return nil end\n  local id = ids[1]\n  if not faceDown then id = ids[n] end\n  if type(id) ~= \"number\" then return nil end\n  -- THE BASE'S OWN RESOLVER, and the shapes deckPreviewSprite answers with, so\n  -- the branch below this cannot tell where the preview came from.\n  local front, back, w, h, idx, ub = tryCustomDeckImagesByCardIDFromCustomDeck(r.cd, id)\n  if not front and not back then return nil end\n  if faceDown then\n    if not back or back == \"\" then return nil end\n    if ub then\n      -- A UNIQUE back IS a sprite sheet, and now that the card's number is\n      -- known the right cell can be cut out of it -- which is the one thing\n      -- AUTO.bigDeckBack above has to decline.\n      return { url = back, w = w, h = h, i = idx, isBack = true, backIsSheet = true, cardID = id }\n    end\n    return { url = back, isBack = true, cardID = id }\n  end\n  if not front or front == \"\" then return nil end\n  return { url = front, w = w, h = h, i = idx, isBack = false, cardID = id }\nend\n\n-- THE ONE DELIBERATE getData ABOVE THE CAP, made here and in no other place.\n-- Called by AUTO.rosterService alone, at most once per full tick across the\n-- whole table. Every engine call is pcall'd: the reference may be nil, and a\n-- deck that has gone into a bag or been merged away is simply dropped.\nAUTO.rosterRead = function(g, r, now)\n  local okO, o = pcall(function() return getObjectFromGUID(g) end)\n  if not okO or o == nil then\n    -- Destroyed, merged away, or inside a container. A deck that comes back is\n    -- learned again on its next fragment build.\n    AUTO.deckRoster[g] = nil\n    return false\n  end\n  -- STAMPED BEFORE THE READ, so a deck whose getData keeps failing is retried\n  -- at the MIN_GAP rate like any other and not on every single tick.\n  r.readAt = now\n  local okD, data = pcall(function() return o.getData() end)\n  if not okD or type(data) ~= \"table\" or type(data.DeckIDs) ~= \"table\" then\n    -- Left dirty on purpose: nothing about the deck is known any better than it\n    -- was, so the next eligible tick tries again.\n    return false\n  end\n  local src = data.DeckIDs\n  local ids = {}\n  local n = 0\n  for i = 1, #src do\n    local v = tonumber(src[i])\n    -- One unreadable entry and every POSITION after it is wrong, and a position\n    -- is the only thing this roster knows a card by. Better no roster than a\n    -- shifted one: the record keeps whatever it had and stays dirty.\n    if v == nil then return false end\n    n = n + 1\n    ids[n] = v\n  end\n  r.ids = ids\n  r.n = n\n  -- THE SHEETS ONLY, never the whole serialisation: `data` holds one entry per\n  -- contained card and must not be kept alive by this record. `data.CustomDeck`\n  -- is the small map of sheet number -> face / back URL and grid.\n  r.cd = data.CustomDeck\n  r.dirty = false\n  AUTO.forceEmit(g)\n  -- ... and on the hot cadence as well (2026-09-17), so the top card CORRECTED by\n  -- this read -- after a shuffle, or after a card was pushed into the middle --\n  -- rides the next hot post rather than waiting for the ordinary diff to be\n  -- built, blocked or turned away. `o` has just answered getData, so the\n  -- reference is live.\n  AUTO.rosterNudge(o, g)\n  return true\nend\n\n-- ONE CALL PER FULL TICK, from tick A -- see pollLoop, which says why that half.\n-- Returns on the first field read when nothing is dirty, which is every tick on\n-- a settled table, and does not loop at all on a table with no big deck on it.\n--\n-- AT MOST ONE READ PER TICK whatever is dirty: this returns the moment it has\n-- made one. With two dirty decks the second is served by a later tick, so two\n-- big decks changing at once cost two reads half a second apart rather than\n-- 50 ms in one frame.\n--\n-- WHEN: the table is QUIET -- nothing picked up and nothing moving, which is\n-- what the hot set holds, emptied AUTO.HOT_SECONDS after the last movement --\n-- or the record has been dirty for AUTO.ROSTER_CAP_WAIT, which is the promise\n-- that a player who keeps dragging things cannot postpone the read for ever.\n-- And never less than AUTO.ROSTER_MIN_GAP after this deck's last read.\nAUTO.rosterService = function(now)\n  local quiet = (next(AUTO.hot) == nil)\n  for g, r in pairs(AUTO.deckRoster) do\n    if r.dirty and (now - (r.readAt or 0)) >= AUTO.ROSTER_MIN_GAP\n       and (quiet or (now - (r.dirtyAt or 0)) >= AUTO.ROSTER_CAP_WAIT) then\n      -- Setting the key pairs() is CURRENTLY on to nil is explicitly allowed,\n      -- and AUTO.rosterRead does exactly that for a deck that has gone; the\n      -- return leaves the traversal immediately either way.\n      AUTO.rosterRead(g, r, now)\n      return\n    end\n  end\nend\n\n-- SEND THE DECK ON THE HOT CADENCE, NOT THE ORDINARY ONE  (2026-09-17)\n--\n-- WHAT IT FIXES, watched in game on build 4871f5bf: a draw pops the roster\n-- instantly and correctly, but the DECK's fragment only went out with the\n-- ordinary diff -- which is tick B's, at the half-scan cadence -- so the card\n-- appeared in the player's hand within 0.25 s (it is hot: onObjectLeaveContainer\n-- marks it) while the deck behind it went on showing the card that had just been\n-- taken for a second or two. The two halves of one action arrived a second\n-- apart.\n--\n-- So the deck is marked HOT too, and rides the next hot post with the card.\n-- This is AUTO.flightFrame's zone attachment, line for line, and for the same\n-- reason: a hot post only carries objects whose hot entry KNOWS its zone\n-- (AUTO.hotListFor skips an entry with no zg), AUTO.markHot cannot know one, and\n-- the half-scan that would attach it is a tick away. Without walking anything\n-- this build can name two zones: its OWN auto zone, and the zone a position\n-- stands in (AUTO.zoneAt, 2026-09-17).\n--\n-- WITH HAND-DRAWN ZONES it is NOT slower (since 2026-09-17). AUTO.zoneGuid is\n-- nil there, so the deck's own position names the zone it stands in (below),\n-- and the deck rides the very next hot post, as on the auto zone. Only when no\n-- zone can be named (the position read failed) does the entry start with\n-- zg = nil; then the first tick after the draw has nothing publishable, and\n-- AUTO.noteSeen writes the real zone into ANY hot entry the half-scan finds,\n-- movement or no movement, while the ordinary diff carries the deck exactly as\n-- it did before this change. Nothing is lost either way.\n--\n-- The zone is only filled in when the entry does NOT already know one -- the\n-- half-scan's answer is the better one wherever it exists, because it is the\n-- zone the object is really in rather than the only zone we can name.\n--\n-- COST: one hash write, one hash read and a flag, on a container event. Nothing\n-- per tick and nothing per object. AUTO.markHot returns at its first line when\n-- broadcasting is off, so a table LOAD cannot fill the hot set through this.\nAUTO.rosterNudge = function(o, g)\n  if o == nil or type(g) ~= \"string\" or g == \"\" then return end\n  AUTO.markHot(o)\n  local e = AUTO.hot[g]\n  -- nil means AUTO.markHot declined -- broadcasting is off, or the reference\n  -- would not give up its GUID. Either way there is no entry to publish.\n  if e == nil then return end\n  if e.zg == nil then\n    local zg = AUTO.zoneGuid\n    -- ... and where this build drew no zone, the one the deck is STANDING IN\n    -- (2026-09-17). A deck does not move, so its own position is the whole\n    -- answer, and it means a hand-drawn table gets the fast path on the very next\n    -- tick instead of waiting for the half-scan to walk the deck's half.\n    if not (type(zg) == \"string\" and zg ~= \"\") then\n      local okP, pp = pcall(function() return o.getPosition() end)\n      if okP and type(pp) == \"table\" then zg = AUTO.zoneAt(pp) end\n    end\n    if type(zg) == \"string\" and zg ~= \"\" then e.zg = zg end\n  end\n  -- Raised whether or not a zone could be attached. With none, the next tick's\n  -- AUTO.hotPublishable finds nothing to send and settles the flag itself, which\n  -- is one walk of a handful of entries -- the same \"nothing to say, say\n  -- nothing\" path an entry that has not moved takes.\n  AUTO.hotPending = true\nend\n\n-- A CARD LEFT THE DECK: the pop, the dirty mark, then the nudge. See the section\n-- head for why the pop is safe and why the mark is made whatever the pop did.\nAUTO.rosterLeave = function(container, obj)\n  local r, g = AUTO.rosterFor(container)\n  if r == nil then return end\n  local ids = r.ids\n  local n = r.n or 0\n  if type(ids) == \"table\" and n > 0 and obj ~= nil then\n    -- THE CARD'S NUMBER, BOTH WAYS ROUND (2026-09-17, after the third in-game\n    -- run: small decks updated on the draw and the 399-card one took a second,\n    -- which is the pop never firing and the deferred read doing all the work).\n    --\n    -- getCardIdRobust tries obj.getCardID() and obj.getCardId(), and NOTHING in\n    -- this repo says either method exists: the Deck Probe read every one of its\n    -- leave lines out of getData() (\"CardID=368515 getData=143 us\"), and the\n    -- base's own tryCustomDeckImagesForCard treats the method as a guess with\n    -- exactly this fallback under it. So the same pair is used here, in the same\n    -- order: the cheap guess first, and the read that is known to work second.\n    --\n    -- THE READ IS ONE CARD, not the deck: 109-147 us measured on the real table\n    -- inside this very event, against 25 ms for the deck it came out of. It is\n    -- paid only for a container that HAS a roster -- the lookup above has already\n    -- answered -- so an ordinary deck and every bag on the table pay nothing.\n    -- Both routes are pcall'd: this runs inside a TTS event hook, where an error\n    -- would break the engine's own dispatch.\n    local okC, cid = pcall(getCardIdRobust, obj)\n    if not (okC and type(cid) == \"number\") then\n      local okD, dcid = pcall(getCardIdFromData, obj)\n      cid = (okD and type(dcid) == \"number\") and dcid or nil\n    end\n    if type(cid) == \"number\" then\n      local okF, fd = pcall(isFaceDown, container)\n      if okF then\n        -- The end the deck is FACING is the end a card comes off: index 1 face\n        -- down, index n face up, the same rule deckPreviewSprite reads by.\n        local i = n\n        if fd then i = 1 end\n        if ids[i] == cid then\n          table.remove(ids, i)\n          r.n = n - 1\n        end\n      end\n    end\n  end\n  r.dirty = true\n  r.dirtyAt = os.clock()\n  -- ... and out it goes on the hot cadence, with the card that just left it.\n  -- The reference the event handed over is alive by definition, which is the\n  -- only safe way to get one for a container that has just been drawn from.\n  AUTO.rosterNudge(container, g)\nend\n\n-- SOMETHING HAPPENED TO THE DECK THAT THIS CANNOT READ. Two events come here:\n--\n--   * A CARD WENT IN. Nothing is read from it: a plain drop's data is already\n--     gutted by the time the event fires (CardID -1, measured on the real\n--     table), and a search-window insert can land ANYWHERE in the deck, so\n--     there is no position to trust either.\n--   * A SHUFFLE (onObjectRandomize). The base bumps SHUFFLE_EPOCH there, which\n--     moves the deck's signature and rebuilds its fragment -- but that fragment\n--     would be built from the roster's OLD order, so the site went on showing\n--     the card that used to be on top. Seen in game on build 4c029074.\n--     Reordering is not something any cheap read can describe, so the whole\n--     roster has to be read again.\n--\n-- \"Something changed, and I cannot say what\" is the whole of what this knows,\n-- which is why both events want the same two lines. The deferred read is what\n-- actually fixes it, within AUTO.ROSTER_CAP_WAIT.\n--\n-- The shuffle hook has already read the object's GUID for its own epoch bump,\n-- so this pays a second pcall'd getGUID there. A shuffle is a player pressing a\n-- button, not a tick: it is not on any path this file counts reads on.\nAUTO.rosterTouch = function(container)\n  local r = AUTO.rosterFor(container)\n  if r == nil then return end\n  r.dirty = true\n  r.dirtyAt = os.clock()\nend\n\n-- =========================\n-- FLIGHT: ONE POST, THE WHOLE MOVE  (2026-09-08)\n-- =========================\n-- TTS answers obj.getPositionSmooth() / obj.getRotationSmooth() with the\n-- DESTINATION of a smooth move while one is running, and nil otherwise. The\n-- Arkham mod draws a chaos token with takeObject{index=0, position=...,\n-- rotation=...}, which IS a smooth move: for the whole time the token is in the\n-- air the engine will say exactly where it is going.\n--\n-- So we say it ONCE. The object's ordinary upsert gains a `flight` field --\n-- {\"to\":{x,y,z},\"rot\":{x,y,z}} -- carrying where it is GOING, beside the pos and\n-- rot it already carries, which is where it is NOW. The site animates the whole\n-- flight from those two, so the 0.25 s sampling cadence stops mattering for the\n-- one kind of movement that is not a player drag. There is NO loop and no extra\n-- per-tick work: one post per event, and a Wait.frames that exists only while\n-- something is actually in the air.\n--\n-- NOT pickup and NOT drop, deliberately: a player dragging an object is not a\n-- smooth move, so the engine has no destination to give and those two handlers\n-- are untouched.\n--\n-- WHICH ZONE, AND THE BUG THAT WAS (2026-09-17). A flight post is a hot post,\n-- and a hot post only carries objects whose hot entry knows its zone;\n-- AUTO.markHot cannot know one and the walk that would attach it is a whole tick\n-- away, by which time the flight is over. Until 2026-09-17 the only zone this\n-- build could name without walking anything was its OWN auto zone, so on a table\n-- of HAND-DRAWN zones AUTO.zoneGuid was nil and every flight was dropped right\n-- here. That was the whole of the missing chaos-token flight the user recorded\n-- off the live socket: his table's only zone is \"SpectatorTool:Area\", hand-drawn,\n-- so no token had ever animated there.\n--\n-- AUTO.zoneAt answers it from the POSITION instead -- the destination first, then\n-- where the object is now -- so the flight works wherever either end lies inside\n-- a zone, whoever drew it, and with exactly one zone on the table it does not\n-- even read a coordinate. Only when NEITHER end is in any zone is the flight\n-- still dropped, and then the object goes out on the ordinary ticks exactly as\n-- it used to.\n\n-- How many frames to keep asking. TTS arms the smooth move within a frame or\n-- two of the event; six is generous and bounded, and an object that never\n-- answers is dropped -- the ordinary ticks publish it as they always did.\nAUTO.FLIGHT_TRIES = 6\n\n-- A DEBUG-ONLY TRACE THROUGH THE WHOLE PATH (2026-09-17). The chaos-token\n-- flight stopped reaching the wire -- the user's recording of the live socket\n-- shows a plain hot `add` half a second after the bag's update and no `flight`\n-- key anywhere -- and the site's own end is proven fine. There are five places\n-- it can be lost and they are indistinguishable from outside: the note never\n-- made, the arm coalesced into a callback that then returned stale, the frame\n-- finding the reference DEAD, the engine answering no destination for all\n-- AUTO.FLIGHT_TRIES frames, the zone not nameable (AUTO.zoneGuid nil, which is\n-- every table of HAND-DRAWN zones), or the post turned away after the entry's\n-- flight was consumed.\n--\n-- So each of those says so, once, under DEBUG_ENABLED -- the base local the\n-- \"Debug: ON/OFF\" button toggles. Debug OFF costs one nil test per line and\n-- nothing else: no string is built, no concat is made. Every line is prefixed\n-- \"[Spectator] flight\" so one grep of the chat log answers the question.\n--\n-- The event route in: onObjectLeaveContainer and onObjectSpawn, and nothing\n-- else. Cheap on purpose -- these fire for every object in the game.\nAUTO.flightNote = function(o)\n  if not broadcasting then return end\n  if not o then return end\n  local okG, g = pcall(function() return o.getGUID() end)\n  if not okG or type(g) ~= \"string\" or g == \"\" then return end\n  if DEBUG_ENABLED then\n    print(\"[Spectator] flight note \" .. g\n          .. \" deathSeq=\" .. tostring(AUTO.deathSeq)\n          .. \" deathAt=\" .. tostring(AUTO.deathAt[g] or \"-\")\n          .. \" armed=\" .. tostring(AUTO.flightLoop.armed)\n          .. \" broadcasting=\" .. tostring(broadcasting))\n  end\n  AUTO.flightPending[g] = { o = o, at = AUTO.deathSeq, tries = 0 }\n  AUTO.flightArm()\nend\n\n-- ONE armed callback at a time, whatever the burst. A mod spawning forty objects\n-- fires forty events, arms one Wait.frames, and that one callback reads all\n-- forty. `armed` is cleared by the callback itself, first thing.\nAUTO.flightArm = function()\n  local fl = AUTO.flightLoop\n  if fl.armed then\n    if DEBUG_ENABLED then\n      print(\"[Spectator] flight arm: already armed (gen \" .. tostring(fl.gen) .. \")\")\n    end\n    return\n  end\n  if not broadcasting then return end\n  fl.armed = true\n  local gen = fl.gen\n  if DEBUG_ENABLED then print(\"[Spectator] flight arm: gen \" .. tostring(gen)) end\n  Wait.frames(function() AUTO.flightFrame(gen) end, 1)\nend\n\n-- ONE frame's pass over the pending list, then a hot publish if anything in it\n-- turned out to be flying. Re-arms only while something is still waiting for a\n-- destination, and never more than AUTO.FLIGHT_TRIES times for one object.\nAUTO.flightFrame = function(gen)\n  local fl = AUTO.flightLoop\n  fl.armed = false\n  -- Every way this callback can be stale, and all of them DROP the work: the\n  -- broadcast that armed it has stopped or a new room started (both bump the\n  -- generation), broadcasting went off under us, or the warm-up is running --\n  -- during which nothing may be published at all.\n  if fl.gen ~= gen or not broadcasting or AUTO.setup.active then\n    AUTO.flightPending = {}\n    return\n  end\n  local pend = AUTO.flightPending\n  if DEBUG_ENABLED then\n    local pn = 0\n    for _ in pairs(pend) do pn = pn + 1 end\n    -- `ownzone=` is this build's OWN zone, which is nil on a table of hand-drawn\n    -- ones. Until 2026-09-17 that meant no flight could be attached to anything\n    -- and `sent` stayed false for ever -- the bug. It is still worth printing:\n    -- nil here means the zone had to be found by POSITION, and the per-object\n    -- line's `zg=` says which one that turned out to be.\n    print(\"[Spectator] flight frame gen=\" .. tostring(gen)\n          .. \" fl.gen=\" .. tostring(fl.gen)\n          .. \" broadcasting=\" .. tostring(broadcasting)\n          .. \" setup=\" .. tostring(AUTO.setup.active)\n          .. \" pending=\" .. tostring(pn)\n          .. \" ownzone=\" .. tostring(AUTO.zoneGuid or \"nil\"))\n  end\n  local sent, waiting = false, false\n  for g, p in pairs(pend) do\n    local keep = false\n    -- What the engine answered, for the Debug line at the end of this iteration.\n    -- \"skipped\" until a branch below has something better to say.\n    local dbgSmooth = \"skipped\"\n    -- Reading a dead reference raises a .NET error no pcall can see, so liveness\n    -- is settled before anything touches p.o.\n    --\n    -- THE RECORD PROVES ITS OWN REFERENCE (2026-09-13). p.at is the death count at\n    -- which the leave-container / spawn event handed p.o over, so one comparison\n    -- against that stamp answers the whole question, and it answers it about THIS\n    -- reference rather than about the GUID in general. That is what makes a redraw\n    -- fly: the object went into its bag, which is a death, and then came straight\n    -- back out, which is a fresh reference captured after it -- and no set has to\n    -- be cleared for the second half to be true.\n    if AUTO.deadRef(g, p.at) then\n      -- Destroyed, or straight back into a container: nothing to animate.\n    else\n      local o = p.o\n      -- Both smooth reads in ONE pcall: the position target is what decides\n      -- whether a move is running at all, and the rotation is whatever comes\n      -- with it (nil for a move that does not turn the object).\n      local ok, m = pcall(function()\n        local tp = o.getPositionSmooth()\n        if tp == nil then return nil end\n        return { p = tp, r = o.getRotationSmooth() }\n      end)\n      if ok and type(m) == \"table\" and type(m.p) == \"table\" then\n        if DEBUG_ENABLED then\n          dbgSmooth = tostring(m.p.x) .. \",\" .. tostring(m.p.y) .. \",\" .. tostring(m.p.z)\n        end\n        -- It answered, so the reference is alive: refresh the hot entry with it\n        -- and push the expiry out.\n        AUTO.markHot(o)\n        local he = AUTO.hot[g]\n        local zg = AUTO.zoneGuid\n        -- WHICHEVER ZONE THE TOKEN IS FLYING INTO, OR OUT OF (2026-09-17). Before\n        -- this, a table of hand-drawn zones left AUTO.zoneGuid nil and every\n        -- flight was dropped here -- which is what the user's socket recording\n        -- showed. The DESTINATION is asked first, because that is where the object\n        -- is going to be; its current position second, for a token drawn from a\n        -- bag that sits inside a zone and lands outside every one of them.\n        if not (type(zg) == \"string\" and zg ~= \"\") then\n          zg = AUTO.zoneAt(m.p)\n          if zg == nil then\n            local okP, pp = pcall(function() return o.getPosition() end)\n            if okP and type(pp) == \"table\" then zg = AUTO.zoneAt(pp) end\n          end\n        end\n        if he ~= nil and type(zg) == \"string\" and zg ~= \"\" then\n          he.zg = zg\n          local tp = vec3Round(m.p, POS_DECIMALS)\n          local f = { to = { x = tp.x, y = tp.y, z = tp.z } }\n          if type(m.r) == \"table\" then\n            local tr = rotRound(m.r, ROT_DECIMALS)\n            f.rot = { x = tr.x, y = tr.y, z = tr.z }\n          end\n          he.flight = f\n          sent = true\n        end\n      else\n        if DEBUG_ENABLED then dbgSmooth = \"nil\" end\n        p.tries = (p.tries or 0) + 1\n        if p.tries < AUTO.FLIGHT_TRIES then keep = true; waiting = true end\n      end\n    end\n    if DEBUG_ENABLED then\n      -- ONE line per pending object per frame, at the point every branch above\n      -- has had its say. `smooth` is the destination the engine admitted, or nil\n      -- while the move has not started; `flight` is whether the entry really\n      -- ended up carrying one, which is what separates \"the engine said nothing\"\n      -- from \"there was no zone to publish it in\".\n      local he = AUTO.hot[g]\n      print(\"[Spectator] flight \" .. g\n            .. \": dead=\" .. tostring(AUTO.deadRef(g, p.at))\n            .. \" smooth=\" .. tostring(dbgSmooth)\n            .. \" tries=\" .. tostring(p.tries or 0)\n            .. \" keep=\" .. tostring(keep)\n            .. \" zg=\" .. tostring(he ~= nil and (he.zg or \"nil\") or \"-\")\n            .. \" flight=\" .. tostring(he ~= nil and he.flight ~= nil))\n    end\n    if not keep then pend[g] = nil end\n  end\n  if DEBUG_ENABLED then\n    -- EVERYTHING THE POST DEPENDS ON, in one line, before it is attempted: the\n    -- three gates the flight publish shares with the poll's hot tick, and the\n    -- one that is not a gate at all but turns a payload away later (inFlight,\n    -- read by publishIfNeeded's own early return).\n    print(\"[Spectator] flight frame: sent=\" .. tostring(sent)\n          .. \" waiting=\" .. tostring(waiting)\n          .. \" publishable=\" .. tostring(AUTO.hotPublishable())\n          .. \" margin_ok=\" .. tostring((os.clock() + AUTO.HOT_FULL_MARGIN) < nextFullSnapshotAt)\n          .. \" next_full_in=\" .. tostring(nextFullSnapshotAt - os.clock())\n          .. \" inFlight=\" .. tostring(inFlight))\n  end\n  if sent then\n    -- A recorded flight is a hot change like any other. The flag is what opens\n    -- publishIfNeeded's \"nothing has changed\" gate, and -- because it is cleared\n    -- only where a post actually goes out -- it is also what makes the NEXT hot\n    -- tick send the flight if this post is turned away by MIN_POST_INTERVAL or\n    -- by one still in the air.\n    AUTO.hotPending = true\n    -- The same guards the poll's HOT tick publishes under. A hot publish must\n    -- never build a FULL: AUTO.filtered answers with the hot objects alone while\n    -- hotOnly is set, so a full walked through that filter would publish a board\n    -- with the rest of the table missing.\n    if DIFF_ENABLED and nextFullSnapshotAt ~= 0\n       and (os.clock() + AUTO.HOT_FULL_MARGIN) < nextFullSnapshotAt\n       and AUTO.hotPublishable() then\n      AUTO.hotOnly = true\n      -- pcall for ONE reason: hotOnly must be false again on every path out.\n      pcall(publishIfNeeded, false)\n      AUTO.hotOnly = false\n    end\n  end\n  if waiting then AUTO.flightArm() end\nend\n\n-- The flight rides the object's ORDINARY upsert, spliced into the fragment at\n-- SEND TIME and never on the way into FRAG_CACHE: a cached fragment carrying a\n-- flight would replay that flight on every later full, for ever.\n--\n-- Splicing the string is what keeps that true. encodedZoneItemFor has already\n-- cached the clean fragment by the time this runs, so `{\"flight\":...,` replaces\n-- the opening brace of the copy that goes on the wire and nothing else. The\n-- entry's flight is cleared with it, so no later post repeats it; if the post is\n-- turned away before the payload is built this is never reached and the flight\n-- waits for the next one.\n--\n-- WHICHEVER POST CARRIES THE OBJECT CARRIES ITS FLIGHT (2026-09-12). Until then\n-- this returned the fragment untouched unless AUTO.hotOnly was set, so a flight\n-- was delivered only by a HOT post -- and whenever the ORDINARY diff got to the\n-- object first (the hot post turned away by the gate, the whole-table walk\n-- reaching it on the next tick, a hot payload that had nothing else to carry)\n-- the site was handed a plain add or update and animated nothing. The object\n-- then appeared at the END of its flight, already on the mat. The test that\n-- matters was never \"is this a hot post\": it is \"does this object's hot entry\n-- still hold a flight nobody has sent\". So it is the only test now, and the\n-- ordinary path pays one field lookup and one nil test per fragment for it.\nAUTO.flightSplice = function(g, json)\n  local e = AUTO.hot[g]\n  if e == nil then\n    if DEBUG_ENABLED then\n      print(\"[Spectator] flight splice \" .. tostring(g) .. \": no hot entry\")\n    end\n    return json\n  end\n  -- NOTHING is said when the entry simply carries no flight: that is every\n  -- object on the table on every post, and a line each would bury the log.\n  if e.flight == nil then return json end\n  if DEBUG_ENABLED then print(\"[Spectator] flight spliced \" .. tostring(g)) end\n  -- `{}` would splice into `{\"flight\":...,}`, which is not JSON. A fragment is\n  -- never empty, but the cost of being sure is two character compares.\n  if type(json) ~= \"string\" or json:sub(1, 1) ~= \"{\" or json:sub(2, 2) == \"}\" then\n    return json\n  end\n  -- The BUNDLED encoder, as in jencTimed: that went through AUTO.jenc on\n  -- 2026-09-08 and has been back on JSON.encode since 2026-09-09 (see debug/).\n  -- This table is six numbers, so both encoders are free on it anyway. The\n  -- pcall is not belt and braces: a flight is\n  -- spliced into a string that is about to go on the wire, and a raise here\n  -- would cost the whole post.\n  local ok, fj = pcall(function() return JSON.encode(e.flight) end)\n  if not ok or type(fj) ~= \"string\" or fj == \"\" then return json end\n  e.flight = nil\n  return '{\"flight\":' .. fj .. \",\" .. json:sub(2)\nend\n\n-- =========================\n-- A LEAN JSON ENCODER  (2026-09-08) -- BENCHED OFF THE PAYLOAD PATH 2026-09-09\n-- =========================\n-- WHAT IT WAS FOR: the bundled encoder. TTS bundles a PURE-LUA JSON encoder, and\n-- the in-game \"Setup slowest\" phase breakdown said it was the single most\n-- expensive thing this tool does -- 72 to 74 ms of a 76 to 93 ms fragment,\n-- roughly 3-4 ms per kilobyte of output, plus about 4 ms on every 930-byte token\n-- post.\n--\n-- WHAT HAPPENED THE FIRST TIME IT WAS TRIED. jencTimed called this instead for\n-- one afternoon. The next setup in game:\n--\n--   545.0 ms Deck \"Deck\" (396)    [frag 545 (json 525)]   -- was json 72\n--   383.8 ms Tile \"Player Cards\"  [frag 374 (json 372)]   -- was json 74\n--\n-- Small posts got cheaper (32.65 ms of encoding per window against 70.77) and\n-- big fragments got five to seven times worse -- the signature of a quadratic\n-- append, which is exactly what it was: the first cut appended with\n-- `out[#out + 1]`, and MoonSharp's `#` on a growing table is a search, not a\n-- field. So the swap came straight back out and the append was fixed (see\n-- AUTO.jencPut below, which carries the write position through the recursion and\n-- never asks a table its length).\n--\n-- THEN A ONE-OBJECT BENCH SAID IT WAS WORTH INSTALLING, and jencTimed was wired\n-- here again for a day: room N1KT put the 396-card deck at 38.8 / 58.4 / 55.2 ms\n-- bundled against 22.9 / 22.8 / 22.1 lean.\n--\n-- AND THEN THE THREE-OBJECT BENCH SAID IT WAS NOT, room 97V0 on 2026-09-09,\n-- which is where this rests:\n--\n--   Player Cards   JSON.encode 41.2 / 38.8 / 38.2  |  AUTO.jenc 23.6 / 32.8 / 46.0\n--   Tarot Deck                 15.7 / 15.8 / 18.9  |            21.0 / 19.9 / 10.5\n--   Deck (396)                 39.7 / 41.8 / 38.6  |            22.2 / 26.5 / 51.2\n--\n-- Outputs equal every time. Averaged over the nine runs the lean encoder is about\n-- 15% faster, but it swings by 2x from run to run -- most likely the GC\n-- collecting the many small strings it builds -- while the bundled one is steady,\n-- and on two of the three objects a lean run was the SLOWEST number on the line.\n-- Fifteen per cent of the encoding does not pay for a second encoder on the path\n-- every payload and every fragment takes, so jencTimed is back on JSON.encode,\n-- all three exits, and this code stays for the Debug bench alone. The bench STAYS\n-- with it: it is the only instrument that can say the number is still true, and\n-- the rule out of both attempts is that nothing gets re-wired without reading it.\n--\n-- CORRECTNESS WAS NEVER THE RISK. AUTO.jenc pcalls the whole encode and falls\n-- back to the bundled encoder on any error at all, and\n-- tests/lua/lean-json/jenc-test.mjs runs it against every zone item of a\n-- captured room.\n\n-- THE ESCAPE TABLE, built rather than spelled out, and it covers EVERY character\n-- %c can match in MoonSharp. MoonSharp strings are .NET strings and its %c is\n-- char.IsControl, which is true for U+0000-U+001F AND U+007F-U+009F -- and gsub\n-- with a TABLE replacement leaves a matched character UNCHANGED when the table\n-- has no entry for it, so a gap here would put a raw control character straight\n-- into the payload rather than failing loudly. Everything else passes through\n-- untouched, raw UTF-8 included, which is valid JSON.\nAUTO.JENC_ESC = { ['\"'] = '\\\\\"', ['\\\\'] = '\\\\\\\\' }\nfor i = 0, 31 do AUTO.JENC_ESC[string.char(i)] = string.format(\"\\\\u%04x\", i) end\nfor i = 127, 159 do AUTO.JENC_ESC[string.char(i)] = string.format(\"\\\\u%04x\", i) end\n-- ... and the five characters with a shorter spelling, written after the loop so\n-- they overwrite the \\uXXXX form rather than racing it.\nAUTO.JENC_ESC[\"\\b\"] = \"\\\\b\"\nAUTO.JENC_ESC[\"\\f\"] = \"\\\\f\"\nAUTO.JENC_ESC[\"\\n\"] = \"\\\\n\"\nAUTO.JENC_ESC[\"\\r\"] = \"\\\\r\"\nAUTO.JENC_ESC[\"\\t\"] = \"\\\\t\"\n\n-- The recursive half. Everything it produces is appended to ONE flat array,\n-- which AUTO.jenc joins in a single table.concat; appending is a native\n-- operation, so no string is ever built by concatenation here.\n--\n-- THE LENGTH OPERATOR IS NOT USED, AND THAT IS THE POINT (fixed 2026-09-08,\n-- after the in-game measurement). The first cut appended with `out[#out + 1] =\n-- piece`, which reads as O(1) and is not: MoonSharp's `#` on a growing table is\n-- a search, so a fragment of a few thousand pieces spent its time measuring the\n-- array rather than filling it -- the 396-card deck went from 72 ms of encoding\n-- to 525. The write position is now carried THROUGH the recursion instead: every\n-- call takes the last index used and hands back the new one, so appending really\n-- is one store. Nothing in here may go back to `#`.\n--\n-- `nil` values need no test: pairs() cannot hand one over, and a nil at an array\n-- slot below the count is impossible on the branch that decides an array.\nAUTO.jencPut = function(v, out, n)\n  local ty = type(v)\n  if ty == \"string\" then\n    out[n + 1] = '\"'\n    -- The parentheses matter: gsub answers with a count as well, and only the\n    -- string may go into the array.\n    out[n + 2] = (v:gsub('[%c\"\\\\]', AUTO.JENC_ESC))\n    out[n + 3] = '\"'\n    return n + 3\n  end\n  if ty == \"number\" then\n    -- NaN and the infinities have no JSON spelling at all. `null` is the one\n    -- answer that survives the round trip.\n    if v ~= v or v == math.huge or v == -math.huge then\n      out[n + 1] = \"null\"\n    elseif v == math.floor(v) and v >= -9007199254740992 and v <= 9007199254740992 then\n      out[n + 1] = string.format(\"%d\", v)\n    else\n      out[n + 1] = string.format(\"%.14g\", v)\n    end\n    return n + 1\n  end\n  if ty == \"boolean\" then out[n + 1] = v and \"true\" or \"false\"; return n + 1 end\n  if ty ~= \"table\" then out[n + 1] = \"null\"; return n + 1 end\n  -- ARRAY OR OBJECT, decided in ONE pass over pairs and without asking the\n  -- table its length either. Count the keys and remember the largest; it is an\n  -- array exactly when every key was a positive integer AND the largest equals\n  -- the count, which is 1..cnt with no holes. The loop gives up on the first key\n  -- that is not a positive integer, so a table mixing integer and string keys\n  -- falls through to the object branch with its integer keys stringified, and a\n  -- sparse array (cnt 2, largest 3) does the same.\n  local cnt, maxk = 0, 0\n  for k in pairs(v) do\n    if type(k) ~= \"number\" or k < 1 or k % 1 ~= 0 then cnt = -1; break end\n    cnt = cnt + 1\n    if k > maxk then maxk = k end\n  end\n  -- An EMPTY table is `[]`. That is what TTS's own encoder answers and what the\n  -- site expects everywhere a list can come back empty -- `{}` would be read as\n  -- an object and break the consumer. cnt is 0 only when pairs yielded nothing:\n  -- a table that broke out of the loop carries -1.\n  if cnt == 0 then out[n + 1] = \"[]\"; return n + 1 end\n  if cnt == maxk then\n    n = n + 1; out[n] = \"[\"\n    for i = 1, cnt do\n      if i > 1 then n = n + 1; out[n] = \",\" end\n      n = AUTO.jencPut(v[i], out, n)\n    end\n    n = n + 1; out[n] = \"]\"\n    return n\n  end\n  n = n + 1; out[n] = \"{\"\n  local first = true\n  for k, val in pairs(v) do\n    if first then first = false else n = n + 1; out[n] = \",\" end\n    n = n + 1; out[n] = '\"'\n    n = n + 1; out[n] = (tostring(k):gsub('[%c\"\\\\]', AUTO.JENC_ESC))\n    n = n + 1; out[n] = '\":'\n    n = AUTO.jencPut(val, out, n)\n  end\n  n = n + 1; out[n] = \"}\"\n  return n\nend\n\n-- The door. Recursion depth is small and fragments hold no cycles, but the pcall\n-- costs nothing per byte and is what makes this safe to keep in the build: on\n-- ANY error the answer is exactly the answer the bundled encoder gives.\nAUTO.jenc = function(v)\n  local out = {}\n  if not pcall(AUTO.jencPut, v, out, 0) then return JSON.encode(v) end\n  return table.concat(out)\nend\n\n-- =========================\n-- PRESENCE: POINTERS AND PINGS  (Spectator Tool Autodraw build only, 2026-09-15)\n-- =========================\n-- WHAT IT IS FOR: the site draws every seated player's cursor and their pings,\n-- so a spectator can follow what the table is LOOKING at and not only what it\n-- moved. Two things make that affordable. The sampler walks PLAYERS -- at most\n-- twelve, one engine read each -- and never objects, so nothing is added to the\n-- per-object per-tick scan or to lightObjSig. And the payload rides whatever\n-- post is already going out: a presence request of its own only happens when no\n-- STATE post has gone for half a second, which on an active table never does.\n--\n-- NOTHING HERE MAY COST A STATE POST, with ONE accepted exception (2026-09-29,\n-- below). The fields are spliced onto a body that is already built (the same\n-- trick AUTO.flightSplice plays), the standalone post never assigns inFlight /\n-- lastPostAt / DIFF_SEQ / retryAttempts and never calls scheduleRetry, and a\n-- failed presence post is dropped or re-buffered -- never escalated into the\n-- \"retry with a FULL snapshot\" path.\n--\n-- THE EXCEPTION (2026-09-29): while a standalone presence post is unanswered,\n-- publishIfNeeded holds the next state post back (a forced one becomes a full\n-- that is owed), so the hand trail and the hold events ride the post that\n-- follows instead of being overtaken by it. A table change can wait up to one\n-- presence round trip; see broadcast/1860 and the presence README. A presence\n-- post that never got an answer would now hold state posts back too, as an\n-- unanswered state post already does.\n\n-- Every number the presence layer has, in ONE table on AUTO: the top-level\n-- locals budget (Lua's 200; a named rule in tts/build/rules.json pins the\n-- count) forbids a local, and one block keeps the tuning in one place.\n--\n--   SIG_DECIMALS      change detection -- a colour's x/z rounded to 1 dp, so a\n--                     hand resting on a mouse does not post four times a second\n--   WIRE_DECIMALS     what goes on the wire, 2 dp, as every other coordinate\n--   YAW_SIG_STEP      the same idea for the pointer's YAW: a slowly turning\n--                     camera must not post four times a second either, so the\n--                     change signature buckets the angle in 5 degree steps --\n--                     the WIRE still carries the full degree the site draws with\n--   STANDALONE_AFTER  seconds since the last STATE post before presence may have\n--                     a request of its own\n--   STANDALONE_HZ     ... and at most this many of those per second\n--   PING_BUF_MAX      buffered pings awaiting a send slot\n--   PING_MAX_AGE      seconds; an unsent ping older than this is dropped rather\n--                     than drawn late somewhere nobody is looking any more\n--   MAX_GONE          TTS has twelve seats, so the pending \"left their seat\" set\n--                     can never honestly need more entries than that\n--   TRAIL_MAX         (2026-09-29) changed samples kept for `pointerTrail`: at a\n--                     sample every 0.25 s, eight is two seconds of hand motion,\n--                     enough to bridge the ordinary round trip and most stalls\n--   HOLDS_MAX         (2026-09-29) buffered pick-up / drop / leave-container\n--                     events awaiting a send slot, the same bound as pings\n--   HOLDS_MAX_AGE     seconds, on the os.time() clock the events carry; an\n--                     unsent one older than this is dropped at send time\n--   WITH_MAX          (2026-09-29, riders) guids at most in an \"up\" / \"down\"\n--                     event's `with`, the same bound as `h` and the Worker's\n--   ACT_WINDOW        seconds, on os.clock(): how old the colour's last PickUp\n--                     action may be when a pick-up falls back to its targets.\n--                     The Event Probe saw it fire ONE frame (about 10 ms) before\n--                     onObjectPickUp; 0.1 s is ten frames of slack for a hitch\nAUTO.PRESENCE = {\n  SIG_DECIMALS = 1,\n  WIRE_DECIMALS = 2,\n  YAW_SIG_STEP = 5,\n  STANDALONE_AFTER = 0.5,\n  STANDALONE_HZ = 2,\n  PING_BUF_MAX = 20,\n  PING_MAX_AGE = 5.0,\n  MAX_GONE = 12,\n  MAX_HELD = 8,\n  TRAIL_MAX = 8,\n  HOLDS_MAX = 20,\n  HOLDS_MAX_AGE = 5.0,\n  WITH_MAX = 8,\n  ACT_WINDOW = 0.1,\n}\n\n-- THE WHOLE OF THE PRESENCE STATE, built from scratch at load, at room create\n-- and at every stop: it describes ONE broadcast and nothing in it may outlive\n-- one. The two printf formats are derived from the constants above rather than\n-- written out a second time, and rebuilt with the state because that costs\n-- nothing.\n--\n--   sig            the last pointer signature. \"\" and not nil, so a table with\n--                  nobody seated never emits a frame\n--   frame          the encoded pointers array of the latest CHANGED sample, nil\n--                  once something has carried it. DROPPABLE: the next sample\n--                  supersedes it, so a failed send is never retried\n--   seated         colour -> true for the last sample's seated set\n--   gone / goneN   colours that vacated a seat and have not been delivered. This\n--                  is the site's ONLY cursor removal -- there is no staleness\n--                  timeout -- so unlike a position it SURVIVES a failed send\n--   goneFlight     the set a post in the air is carrying, nil when none is;\n--                  pings / pingsFlight are the same pair for pings\n--   trail          (2026-09-29) EVERY changed sample since the last carry, as\n--                  { t = os.time(), frame = <the same text as frame> }, oldest\n--                  first, at most TRAIL_MAX. Unlike frame it is NOT droppable:\n--                  it is the motion between two posts, which nothing else can\n--                  send later, so trailFlight holds the batch in the air and a\n--                  failure puts it back, exactly like pings\n--   holds          (2026-09-29) pick-up / drop / leave-container events, oldest\n--                  first, at most HOLDS_MAX; holdsFlight is its flight slot\n--   acts           (2026-09-29, riders) colour -> the last PickUp action that\n--                  colour made, { t = os.clock(), g = { guid, ... } } in the\n--                  engine's target order; overwritten by the next one, never\n--                  appended to (AUTO.presAct writes it, AUTO.presWith reads it)\n--   lastStandaloneAt / inFlight   the standalone post's rate limit and its slot\n--   nextWarnAt     throttle for the failure print (Debug only)\nAUTO.presReset = function()\n  local C = AUTO.PRESENCE\n  C.SIG_FMT = \"%.\" .. tostring(C.SIG_DECIMALS) .. \"f,%.\" .. tostring(C.SIG_DECIMALS) .. \"f\"\n  C.WIRE_FMT = \"%.\" .. tostring(C.WIRE_DECIMALS) .. \"f\"\n  AUTO.pres = {\n    sig = \"\",\n    frame = nil,\n    seated = {},\n    gone = {}, goneN = 0,\n    goneFlight = nil,\n    pings = {},\n    pingsFlight = nil,\n    trail = {},\n    trailFlight = nil,\n    holds = {},\n    holdsFlight = nil,\n    acts = {},\n    lastStandaloneAt = -1e9,\n    nextWarnAt = 0,\n    inFlight = false,\n  }\nend\nAUTO.presReset()\n\n-- ONE SAMPLE PER TICK, from pollLoop and nowhere else. `now` is the tick's clock,\n-- taken for symmetry with the other per-tick calls; the sampler itself needs no\n-- time.\n--\n-- GREY IS IN THE LIST, and `seated` is no help (Seat Probe, 2026-09-15): the\n-- host gone Grey is still returned by Player.getPlayers() with seated == true,\n-- a hand count of 0, and hand reads that raise the .NET NullReferenceException\n-- pcall cannot catch. So the COLOUR is the test: \"Grey\" is skipped before any\n-- engine read is made on it, and the seated == true test after it is only the\n-- documented belt and braces. A player who drops to Grey mid-game therefore\n-- leaves the seated set, which the seat diff turns into a `gone` entry and the\n-- site uses to remove the cursor.\n--\n-- The pointer read never fails and never answers nil for a seated player -- it\n-- is always the ray hit on whatever lies under the cursor, sky and void included\n-- (lobby check part 2, same day) -- so there is NO y cap and no staleness guard\n-- here: a hit far outside every zone is the site's business, and its \"hidden\n-- outside every card\" rule already covers it. The pcall stays because an engine\n-- read that raised inside a tick would cost the whole tick.\n--\n-- No object is read and no object reference is stored. The two strings are built\n-- in the same pass: sigParts at 1 dp decides whether anything CHANGED, parts at\n-- 2 dp is what goes on the wire if it did.\n--\n-- TWO MORE FIELDS PER CURSOR (2026-09-15), both optional, both read only AFTER\n-- the position read has already succeeded -- a seat that was never going to be\n-- sent must not cost two extra engine calls -- and each inside a nested pcall of\n-- its own. The outer pcall would drop the WHOLE player if a later TTS retired\n-- either call, and a cursor with no facing is still a cursor worth drawing.\n-- `rot` and `hold` below are the wire's optional \"r\" and \"h\".\n--\n--   r  the pointer's yaw in whole degrees, 0-359. Measured today (Seat Probe) as\n--      a plain NUMBER that follows the camera; a table with a .y is accepted too\n--      in case a later TTS answers a Vector, and anything else means no r at all\n--      rather than a guess. A NaN or an infinity is thrown away the SAME way, and\n--      that is not a tidiness rule: math.floor leaves both alone, so the degree\n--      would reach the wire as a bare nan / inf, which is not JSON. The body it\n--      poisoned would be a snapshot post -- a 400 the retry path would then send\n--      again, forever, because no amount of retrying makes that body valid.\n--      YAW_SIG_STEP buckets it in the change signature so a slow turn is not a\n--      post per tick; the wire keeps the full degree.\n--   h  the LIST of guids that player is HOLDING, at most C.MAX_HELD of them, and\n--      absent entirely when the hand is empty -- never an empty list, never a 0\n--      the site would have to ignore. It was the bare number 1 until 2026-09-17:\n--      the site drew \"this cursor is carrying\" from the fact, and now moves the\n--      held object with the hand, which needs the guid. The list is folded into\n--      the change signature too, or picking up a second object would move\n--      nothing the sampler can see and the frame would never go out.\n--      The relay was widened the same day (2026-09-17, worker/index.js\n--      cleanPresencePoints): it passes both the old number 1 and this array, the\n--      latter rebuilt member by member and capped at its own HELD_MAX of 8.\n--\n-- Both ride the same signature, so picking a card up without moving the mouse IS\n-- a change and does post.\n--\n-- THE HAND TRAIL (2026-09-29). Until this date a changed sample OVERWROTE\n-- P.frame, so only the newest sample per post ever left the game: with one post\n-- in flight and a 0.25-0.5 s round trip about half the samples were lost, and a\n-- network stall erased seconds of motion (the drag analysis of 2026-09-29,\n-- artifacts/cache-layer/drag-analysis/report.md: 22 of 34 drags fell entirely\n-- between two pictures). Every changed sample is now ALSO appended to P.trail\n-- with its own time, t = os.time() -- the clock the body's `ts` is written on,\n-- seconds with fractions -- so the site can place each picture where it was\n-- taken. At most C.TRAIL_MAX; the oldest goes first. P.frame is still the newest,\n-- exactly as before, so `pointers` on the wire has not changed.\nAUTO.presSample = function(now)\n  local P = AUTO.pres\n  if P == nil then return end\n  local okP, players = pcall(function() return Player.getPlayers() end)\n  if not okP or type(players) ~= \"table\" then return end\n  local C = AUTO.PRESENCE\n  local sigParts, parts, n = {}, {}, 0\n  local seatedNow = {}\n  for i = 1, #players do\n    local pl = players[i]\n    local okR, color, x, z, rot, hold = pcall(function()\n      local c = pl.color\n      if type(c) ~= \"string\" or c == \"Grey\" then return nil end\n      if pl.seated ~= true then return nil end\n      local pos = pl.getPointerPosition()\n      if type(pos) ~= \"table\" then return nil end\n      local r = nil\n      local okY, yaw = pcall(function() return pl.getPointerRotation() end)\n      if okY then\n        if type(yaw) == \"number\" then r = yaw\n        elseif type(yaw) == \"table\" and type(yaw.y) == \"number\" then r = yaw.y end\n      end\n      if r ~= nil and (r ~= r or r > 1e6 or r < -1e6) then r = nil end\n      -- WHAT THIS PLAYER IS HOLDING, BY GUID (2026-09-17). It used to be the\n      -- bare fact -- `h` was 1 and the site drew a grab hand -- and the site now\n      -- wants to MOVE the held object with the hand, which needs to know WHICH\n      -- object. At most AUTO.PRESENCE.MAX_HELD of them: a player can hold a\n      -- stack, and the wire is not the place to find out how big it is.\n      --\n      -- One pcall'd getGUID per held object, so a player holding nothing pays\n      -- exactly what it paid before (one getHoldingObjects) and a player holding\n      -- something pays at most eight cheap reads, once per tick, on the sample\n      -- that already runs once per tick. Nothing per object on the table.\n      --\n      -- THE %w TEST IS NOT DECORATION: these guids are concatenated straight into\n      -- the body, so one containing a quote would produce a payload that is not\n      -- JSON -- a 400 on a snapshot post, and a body the retry path could never\n      -- make valid. A TTS guid is six alphanumerics; anything else is dropped.\n      local okH, held = pcall(function() return pl.getHoldingObjects() end)\n      local hs, hn = nil, 0\n      if okH and type(held) == \"table\" then\n        local m = #held\n        if m > C.MAX_HELD then m = C.MAX_HELD end\n        for k = 1, m do\n          local okG, hg = pcall(function() return held[k].getGUID() end)\n          if okG and type(hg) == \"string\" and hg:match(\"^%w+$\") then\n            if hs == nil then hs = {} end\n            hn = hn + 1\n            hs[hn] = hg\n          end\n        end\n      end\n      return c, pos.x, pos.z, r, hs\n    end)\n    if okR and type(color) == \"string\" and color:match(\"^%a+$\")\n       and type(x) == \"number\" and type(z) == \"number\" then\n      seatedNow[color] = true\n      n = n + 1\n      sigParts[n] = color .. string.format(C.SIG_FMT, x, z)\n                    .. (rot and (\"/\" .. math.floor(rot / C.YAW_SIG_STEP)) or \"\")\n                    .. (hold and (\"H\" .. table.concat(hold, \"+\")) or \"\")\n      parts[n] = '{\"color\":\"' .. color .. '\",\"x\":' .. string.format(C.WIRE_FMT, x)\n                 .. ',\"z\":' .. string.format(C.WIRE_FMT, z)\n                 .. (rot and ',\"r\":' .. tostring(math.floor(rot + 0.5) % 360) or \"\")\n                 .. (hold and (',\"h\":[\"' .. table.concat(hold, '\",\"') .. '\"]') or \"\")\n                 .. '}'\n    end\n  end\n  for c in pairs(P.seated) do\n    if seatedNow[c] == nil and P.gone[c] == nil and P.goneN < C.MAX_GONE then\n      P.gone[c] = true\n      P.goneN = P.goneN + 1\n    end\n  end\n  for c in pairs(seatedNow) do\n    if P.gone[c] ~= nil then\n      P.gone[c] = nil\n      P.goneN = P.goneN - 1\n    end\n  end\n  P.seated = seatedNow\n  local sig = table.concat(sigParts, \";\")\n  if sig ~= P.sig then\n    P.sig = sig\n    P.frame = \"[\" .. table.concat(parts, \",\") .. \"]\"\n    local trail = P.trail\n    if #trail >= C.TRAIL_MAX then table.remove(trail, 1) end\n    trail[#trail + 1] = { t = os.time(), frame = P.frame }\n  end\nend\n\n-- ONE AGE PURGE, TWO CALLERS (2026-09-15). Both send paths drop a ping older\n-- than PING_MAX_AGE before they look at the buffer: the splice because a post is\n-- going out NOW and a ping nobody can still be looking for must not ride it, the\n-- standalone because a buffer holding nothing but stale pings is not a reason to\n-- send a request of its own. That rule was written out twice, which is one place\n-- too many for the thing the site's ping drawing depends on. P.pings is left\n-- holding the kept list, exactly as both callers left it; the count comes back\n-- beside it because the splice needs the list and the standalone only needs to\n-- know whether there is anything left.\n--\n-- THE HOLD EVENTS USE IT TOO (2026-09-29), through the two optional arguments:\n-- AUTO.presPurge(P, os.time(), \"holds\", C.HOLDS_MAX_AGE). Same rule, another\n-- buffer and another clock: a hold carries its time on the wire, t = os.time(),\n-- so its age is measured on that clock, while a ping never shows its time and\n-- ages on os.clock(). Left off, the two arguments mean pings, exactly as before.\nAUTO.presPurge = function(P, tnow, key, maxAge)\n  key = key or \"pings\"\n  maxAge = maxAge or AUTO.PRESENCE.PING_MAX_AGE\n  local buf, kept, kn = P[key], {}, 0\n  for i = 1, #buf do\n    local e = buf[i]\n    if (tnow - (e.t or 0)) <= maxAge then\n      kn = kn + 1\n      kept[kn] = e\n    end\n  end\n  P[key] = kept\n  return kept, kn\nend\n\n-- ONE HOLD EVENT AS JSON (2026-09-29), for the splice. The keys go in the wire\n-- order k, t, c, g, x, z, px, pz, from, with, and an absent one is left out --\n-- never a null the Worker would have to read as \"no value\". Everything that\n-- reaches here was shape-tested when it was buffered (AUTO.presHold): k is one\n-- of three words, c is letters only, g and from are alphanumeric, every number\n-- is finite and every guid in `with` is alphanumeric too (AUTO.presWith), so\n-- nothing can close a string early or put a bare nan in the body.\n-- `t` is written with tostring, the way every body's `ts` is, so the two read\n-- the same on the wire: seconds on the tool's os.time() clock, with fractions.\n-- `with` (riders, 2026-09-29) is an array of strings, 1 to WITH_MAX of them;\n-- an event with no riders has no `with` at all, not an empty array.\nAUTO.presHoldJson = function(e)\n  local F = AUTO.PRESENCE.WIRE_FMT\n  local s = '{\"k\":\"' .. e.k .. '\",\"t\":' .. tostring(e.t)\n  if e.c ~= nil then s = s .. ',\"c\":\"' .. e.c .. '\"' end\n  s = s .. ',\"g\":\"' .. e.g .. '\"'\n  if e.x ~= nil then\n    s = s .. ',\"x\":' .. string.format(F, e.x) .. ',\"z\":' .. string.format(F, e.z)\n  end\n  if e.px ~= nil then\n    s = s .. ',\"px\":' .. string.format(F, e.px) .. ',\"pz\":' .. string.format(F, e.pz)\n  end\n  if e.from ~= nil then s = s .. ',\"from\":\"' .. e.from .. '\"' end\n  if e.with ~= nil and #e.with > 0 then\n    s = s .. ',\"with\":[\"' .. table.concat(e.with, '\",\"') .. '\"]'\n  end\n  return s .. \"}\"\nend\n\n-- THE SPLICE, and on an active table it is the whole transport. `body` is a JSON\n-- object string about to go on the wire; the pending presence fields are\n-- appended before its closing brace and the caller is told what was taken, so a\n-- failed post can put back whatever did not arrive. The guard is the same two\n-- character compares AUTO.flightSplice makes: a body that is not a string, or\n-- does not end in `}`, is handed straight back untouched.\n--\n-- WHAT IS DROPPABLE AND WHAT IS NOT is the only thing to understand here.\n-- `pointers` is a POSITION: superseded by the next sample, so nothing tracks it\n-- beyond carried.pointers. `gone` and `pings` are EVENTS -- the site has no\n-- other way to learn either -- so each moves into a flight slot, only one batch\n-- of each can be in the air at a time, and a failure puts it back.\n--\n-- TWO MORE EVENT FIELDS (2026-09-29), both in flight slots of their own:\n--   pointerTrail  every changed sample since the last carry, oldest first, each\n--                 {\"t\":<os.time() at the sample>,\"pointers\":<that frame>} -- the\n--                 newest is also the plain `pointers` above, which stays as it\n--                 was for anything that reads only that. The Worker relays each\n--                 entry as a pointers frame of its own, stamped with its `t`, and\n--                 then does not relay the plain field (it is the last entry).\n--   holds         the buffered pick-up / drop / leave-container events, older\n--                 than HOLDS_MAX_AGE dropped first, as AUTO.presHoldJson writes\n--                 them.\n-- Field order in the body: pointers, pointerTrail, gone, holds, pings.\nAUTO.presSplice = function(body)\n  local P = AUTO.pres\n  if P == nil then return body, nil end\n  if type(body) ~= \"string\" or body:sub(-1) ~= \"}\" then return body, nil end\n  local C = AUTO.PRESENCE\n  local fields, fn, carried = {}, 0, nil\n  if P.frame ~= nil then\n    fn = fn + 1\n    fields[fn] = ',\"pointers\":' .. P.frame\n    P.frame = nil\n    carried = carried or {}\n    carried.pointers = true\n  end\n  if P.trailFlight == nil and #P.trail > 0 then\n    local tr, q = P.trail, {}\n    for i = 1, #tr do\n      q[i] = '{\"t\":' .. tostring(tr[i].t) .. ',\"pointers\":' .. tr[i].frame .. '}'\n    end\n    P.trail = {}\n    P.trailFlight = tr\n    fn = fn + 1\n    fields[fn] = ',\"pointerTrail\":[' .. table.concat(q, \",\") .. \"]\"\n    carried = carried or {}\n    carried.trail = tr\n  end\n  if P.goneN > 0 and P.goneFlight == nil then\n    local arr, q, an = {}, {}, 0\n    for c in pairs(P.gone) do\n      an = an + 1\n      arr[an] = c\n      q[an] = '\"' .. c .. '\"'\n    end\n    P.gone = {}\n    P.goneN = 0\n    P.goneFlight = arr\n    fn = fn + 1\n    fields[fn] = ',\"gone\":[' .. table.concat(q, \",\") .. \"]\"\n    carried = carried or {}\n    carried.gone = arr\n  end\n  if P.holdsFlight == nil then\n    local kept, kn = AUTO.presPurge(P, os.time(), \"holds\", C.HOLDS_MAX_AGE)\n    if kn > 0 then\n      local q = {}\n      for i = 1, kn do q[i] = AUTO.presHoldJson(kept[i]) end\n      P.holds = {}\n      P.holdsFlight = kept\n      fn = fn + 1\n      fields[fn] = ',\"holds\":[' .. table.concat(q, \",\") .. \"]\"\n      carried = carried or {}\n      carried.holds = kept\n    end\n  end\n  if P.pingsFlight == nil then\n    local kept, kn = AUTO.presPurge(P, os.clock())\n    if kn > 0 then\n      local q = {}\n      for i = 1, kn do\n        local e = kept[i]\n        q[i] = '{\"color\":\"' .. e.c .. '\",\"x\":' .. string.format(C.WIRE_FMT, e.x)\n               .. ',\"z\":' .. string.format(C.WIRE_FMT, e.z) .. '}'\n      end\n      P.pings = {}\n      P.pingsFlight = kept\n      fn = fn + 1\n      fields[fn] = ',\"pings\":[' .. table.concat(q, \",\") .. \"]\"\n      carried = carried or {}\n      carried.pings = kept\n    end\n  end\n  if fn == 0 then return body, nil end\n  return body:sub(1, -2) .. table.concat(fields) .. \"}\", carried\nend\n\n-- PUT A FAILED BATCH BACK (2026-09-29). `arr` is what a failed post carried,\n-- `buf` what has been buffered since. The batch goes back in FRONT, because it\n-- is older than anything buffered since, and past `max` the oldest entries are\n-- dropped, from the same end every buffer here drops from. Pings did this\n-- inline from 2026-09-15; the hand trail and the hold events need the same\n-- rule, so it is written once for all three.\nAUTO.presFront = function(arr, buf, max)\n  local merged, mn = {}, 0\n  for i = 1, #arr do\n    mn = mn + 1\n    merged[mn] = arr[i]\n  end\n  for i = 1, #buf do\n    mn = mn + 1\n    merged[mn] = buf[i]\n  end\n  while mn > max do\n    table.remove(merged, 1)\n    mn = mn - 1\n  end\n  return merged\nend\n\n-- THE ANSWER. `carried` is exactly what the splice handed back, so this can only\n-- ever put back what that post really took -- the same argument publishIfNeeded\n-- makes with postedSig.\n--\n-- On success the flight slots are simply emptied. On failure: a `gone` colour\n-- goes back into the pending set UNLESS that player has taken a seat again since\n-- (the site would be told to remove a cursor that is on the board); pings go\n-- back at the FRONT, because they are older than anything buffered since and the\n-- buffer drops from that end; and a pointer frame is not re-queued at all --\n-- instead the signature is cleared, so the NEXT sample re-emits the CURRENT\n-- positions rather than the stale ones this post was carrying.\n--\n-- THE HAND TRAIL AND THE HOLD EVENTS (2026-09-29) are events in this sense, not\n-- positions: the trail is motion that happened between two posts and no later\n-- sample can send it again. Both are handled like pings -- a 200 or 409 empties\n-- the flight slot, a failure puts the batch back in front of whatever came\n-- since (AUTO.presFront, oldest dropped past TRAIL_MAX / HOLDS_MAX). The\n-- pointer signature is still cleared when the plain frame was carried, so the\n-- next sample also adds where everyone is NOW.\nAUTO.presAck = function(carried, ok)\n  if carried == nil then return end\n  local P = AUTO.pres\n  if P == nil then return end\n  local C = AUTO.PRESENCE\n  if ok then\n    if carried.gone ~= nil then P.goneFlight = nil end\n    if carried.pings ~= nil then P.pingsFlight = nil end\n    if carried.trail ~= nil then P.trailFlight = nil end\n    if carried.holds ~= nil then P.holdsFlight = nil end\n    return\n  end\n  if carried.gone ~= nil then\n    local arr = carried.gone\n    for i = 1, #arr do\n      local c = arr[i]\n      if P.seated[c] == nil and P.gone[c] == nil and P.goneN < C.MAX_GONE then\n        P.gone[c] = true\n        P.goneN = P.goneN + 1\n      end\n    end\n    P.goneFlight = nil\n  end\n  if carried.pings ~= nil then\n    P.pings = AUTO.presFront(carried.pings, P.pings, C.PING_BUF_MAX)\n    P.pingsFlight = nil\n  end\n  if carried.trail ~= nil then\n    P.trail = AUTO.presFront(carried.trail, P.trail, C.TRAIL_MAX)\n    P.trailFlight = nil\n  end\n  if carried.holds ~= nil then\n    P.holds = AUTO.presFront(carried.holds, P.holds, C.HOLDS_MAX)\n    P.holdsFlight = nil\n  end\n  if carried.pointers then P.sig = nil end\nend\n\n-- THE QUIET TABLE'S ONLY ROUTE OUT. Nothing has moved, so no state post is due,\n-- so the splice above never runs -- and a cursor would sit frozen until somebody\n-- nudged a card. This sends the presence fields on their own, and every\n-- condition below exists to keep it out of the state machine's way:\n--\n--   * state posts win outright, and so does the warm-up;\n--   * it waits STANDALONE_AFTER seconds since the LAST state post, so it can\n--     never go out just ahead of one that would have carried it for free;\n--   * at most STANDALONE_HZ of them a second, whatever the sample rate;\n--   * and only when something is actually pending.\n--\n-- The label is cosmetic -- the Worker treats \"pointers\" and \"pings\" identically,\n-- as \"an ephemeral body with no seq\" -- but it is set to whichever family is\n-- really driving the post. The callback touches NOTHING the state machine owns,\n-- and a presence failure must never become a forced FULL.\n--\n-- 2026-09-29: a hand trail waiting for a free flight slot counts as pointers,\n-- and hold events waiting for theirs are reason enough on their own\n-- (`wantHolds`); a post driven by holds is labelled \"pointers\" too, since the\n-- Worker knows only the two labels.\nAUTO.presStandalone = function(now)\n  local P = AUTO.pres\n  if P == nil or P.inFlight then return end\n  if inFlight then return end\n  if not roomCode or not writeToken then return end\n  if AUTO.setup.active then return end\n  local C = AUTO.PRESENCE\n  if (now - (lastPostAt or 0)) < C.STANDALONE_AFTER then return end\n  if (now - (P.lastStandaloneAt or 0)) < (1.0 / C.STANDALONE_HZ) then return end\n  local kept, kn = AUTO.presPurge(P, os.clock())\n  local _, hn = AUTO.presPurge(P, os.time(), \"holds\", C.HOLDS_MAX_AGE)\n  local wantPointers = (P.frame ~= nil) or (P.goneN > 0 and P.goneFlight == nil)\n                       or (#P.trail > 0 and P.trailFlight == nil)\n  local wantHolds = (hn > 0 and P.holdsFlight == nil)\n  local wantPings = (kn > 0 and P.pingsFlight == nil)\n  if not (wantPointers or wantHolds or wantPings) then return end\n  local ptype = \"pings\"\n  if wantPointers or wantHolds then ptype = \"pointers\" end\n  local body, carried = AUTO.presSplice('{\"type\":\"' .. ptype .. '\",\"code\":\"' .. roomCode ..\n                                        '\",\"ts\":' .. tostring(os.time()) .. '}')\n  if carried == nil then return end\n  P.inFlight = true\n  P.lastStandaloneAt = now\n  if DEBUG_ENABLED then PROF.presStandalone = PROF.presStandalone + 1 end\n  local headers = {\n    [\"Content-Type\"] = \"application/json\",\n    [\"Authorization\"] = \"Bearer \" .. writeToken\n  }\n  WebRequest.custom(WORKER_BASE .. \"/update/\" .. roomCode, \"POST\", true, body, headers, function(req)\n    P.inFlight = false\n    -- A stop or a room create between the post and this answer has replaced the\n    -- state table; acknowledging into the NEW one would push the old room's\n    -- colours at the new room's site.\n    if AUTO.pres ~= P then return end\n    -- Same rule as the state post's callback: delivered on 200 or 409 (an\n    -- ephemeral-only body never draws a 409; kept identical on purpose).\n    local okReq = (req.response_code == 200 or req.response_code == 409)\n    AUTO.presAck(carried, okReq)\n    if (not okReq) and DEBUG_ENABLED and os.clock() >= (P.nextWarnAt or 0) then\n      P.nextWarnAt = os.clock() + 10.0\n      print(\"[Spectator] presence post failed: \" .. tostring(req.response_code or req.error))\n    end\n  end)\nend\n\n-- THE PING, off the engine's own event. Everything here is a type test: the hook\n-- runs inside TTS's dispatch, where a raise breaks the engine's own loop, and\n-- the global below pcalls the whole of this for that reason.\n--\n-- ASSUMPTION, recorded deliberately: a ping from a NON-SEATED player (a Grey\n-- spectator) is DROPPED, because the site has no seat colour to draw it in and\n-- Player.getPlayers() never showed us that player in the first place. If a\n-- spectator's ping is ever wanted, the wire needs a colour the site can render,\n-- and this is the line to revisit.\nAUTO.presPing = function(player, position)\n  if not broadcasting then return end\n  local P = AUTO.pres\n  if P == nil then return end\n  if player == nil then return end\n  local color = player.color\n  if type(color) ~= \"string\" or color == \"Grey\" or not color:match(\"^%a+$\") then return end\n  if player.seated ~= true then return end\n  if type(position) ~= \"table\" then return end\n  local x, z = position.x, position.z\n  if type(x) ~= \"number\" or type(z) ~= \"number\" then return end\n  local buf = P.pings\n  if #buf >= AUTO.PRESENCE.PING_BUF_MAX then table.remove(buf, 1) end\n  buf[#buf + 1] = { c = color, x = x, z = z, t = os.clock() }\nend\n\n-- WHAT RIDES ON IT (2026-09-29, artifacts/cache-layer/design-5.md part A): the\n-- guids of the objects a picked-up or dropped object carries, for the `with`\n-- field of its \"up\" and \"down\" hold events.\n--\n-- WHY: a CARD attaches what lies on it while a player holds it. The Event Probe\n-- saw it on the user's table the same night (artifacts/event-probe/\n-- run-5-findings.md): with two tokens on a held card, getAttachments() on the\n-- card counted them (att=2; att=3 in another carry) from the pick-up frame on;\n-- the riders vanished from getAllObjects and from every scripting zone, got no\n-- onObjectPickUp of their own, and came back about 0.7 s after the drop, when\n-- the count fell to 0 (drop at 1224 ms, zone enter 1923, att=0 at 2003; the\n-- next two carries 2325 -> 3057 -> 3133 and 4270 -> 4970 -> 5012). So during a\n-- long carry the tool's own diff REMOVES the riders, re-adds them after the\n-- drop, and nothing told the site they were riding. Now the \"up\" names them and\n-- the \"down\" names the ones still attached at the drop (the site reads a rider\n-- missing there as shed on the way) and the site carries them with the card.\n--\n-- WHERE FROM: obj.getAttachments(), one engine call per pick-up and per drop,\n-- in its documented shape (a list like a container's getObjects(), each entry\n-- with a guid); `entry.guid` also reads an object reference. If the call\n-- raises, answers something that is not a table, or answers entries none of\n-- which has a usable guid, an \"up\" falls back to the targets of the same\n-- colour's last PickUp action (AUTO.presAct, from onPlayerAction), which the\n-- probe saw fire ONE frame before onObjectPickUp naming the grabbed card first\n-- and then every rider (`ACT PickUp Teal targets=3 ba4f0c 87f93f 170e6c`, then\n-- `PICK Teal ba4f0c`) -- and never a LOCKED token (779c32 on a deck stayed put\n-- and was not a target). The action counts only when it is at most ACT_WINDOW\n-- old and names this very object. A \"down\" has no such action to fall back on.\n--\n-- AN EMPTY LIST IS BELIEVED, never topped up from the action: a deck lifting\n-- tokens, or a box-selected group, lists its riders in the action too, but each\n-- of those objects gets its own onObjectPickUp, so the tool already sends its\n-- own \"up\" (probe: `PICK Teal 7e9371 Deck`, `RC 7e9371 own pick-up`).\n--\n-- Answers at most WITH_MAX guids, each ^%w+$ (the same shape test as `h` and\n-- `g`: the text is written straight into the body), no repeats, the object's\n-- own guid `g` left out, in the attachment list's order (the action's order\n-- for the fallback); nil when there are none. Every engine read is inside a\n-- pcall, and the caller pcalls this too: it runs in the engine's dispatch.\nAUTO.presWith = function(k, color, obj, g)\n  local C = AUTO.PRESENCE\n  local out, seen = {}, { [g] = true }\n  local add = function(v)\n    if #out < C.WITH_MAX and type(v) == \"string\" and not seen[v] and v:match(\"^%w+$\") then\n      seen[v] = true\n      out[#out + 1] = v\n    end\n  end\n  local okA, n = pcall(function()\n    local list = obj.getAttachments()\n    if type(list) ~= \"table\" then error(\"getAttachments: not a table\") end\n    for i = 1, #list do\n      local a = list[i]\n      if type(a) == \"table\" or type(a) == \"userdata\" then add(a.guid) end\n      if #out >= C.WITH_MAX then break end\n    end\n    return #list\n  end)\n  if not okA or (n > 0 and #out == 0) then\n    out, seen = {}, { [g] = true }\n    local P = AUTO.pres\n    local a = (k == \"up\" and type(color) == \"string\" and P ~= nil) and P.acts[color] or nil\n    if a ~= nil and os.clock() - a.t <= C.ACT_WINDOW then\n      local named = false\n      for i = 1, #a.g do\n        if a.g[i] == g then named = true break end\n      end\n      if named then\n        for i = 1, #a.g do add(a.g[i]) end\n      end\n    end\n  end\n  if #out == 0 then return nil end\n  return out\nend\n\n-- THE HOLD EVENTS (2026-09-29): a pick-up, a drop, or an object taken out of a\n-- container, each buffered with its own time and place for the next post.\n--\n-- WHY: a drag lasts about half a second and a hand picture leaves the game\n-- twice a second at best, so most drags fell entirely between two pictures (22\n-- of 34 in the drag analysis of 2026-09-29, artifacts/cache-layer/drag-analysis/\n-- report.md). The engine already SAYS when a drag starts and ends -- these are\n-- the hot set's own events -- so now the site is told too: \"up\" and \"down\"\n-- bracket the drag with the object's position and the hand's at both ends, and\n-- \"out\" says which container an object has just left. The user's idea, in their\n-- words: \"the tool detecting these events and putting them in order\".\n--\n-- Called ONLY from the three hot-set handlers (onObjectPickUp, onObjectDrop,\n-- onObjectLeaveContainer), each through pcall, because a raise inside the\n-- engine's dispatch breaks more than this tool. It reads the object the event\n-- handed over and, for \"up\" and \"down\", that one player's pointer: nothing per\n-- object on the poll path, nothing on a tick.\n--\n--   k       \"up\" | \"down\" | \"out\"; any other word is refused\n--   t       os.time(), the clock the body's `ts` is on, seconds with fractions\n--   c       the player's colour, letters only; absent for \"out\", because the\n--           engine does not say who drew it (the \"up\" that follows a frame\n--           later does)\n--   g       the object's guid, alphanumeric like `h`; an event without one is\n--           dropped, because the site can do nothing with it\n--   x, z    the object's position at the event, when readable and finite\n--   px, pz  that colour's pointer at the event, \"up\" and \"down\" only, and never\n--           for Grey: its reads raise the .NET error pcall cannot catch (Seat\n--           Probe, 2026-09-15), so the colour is the test, as in the sampler,\n--           and a player who is not seated is not read either\n--   from    the container's guid, \"out\" only\n--   with    (riders, 2026-09-29) \"up\" and \"down\" only: the guids of what the\n--           object carries, AUTO.presWith, read after everything above and\n--           in its own pcall, so a failure there costs `with` and nothing\n--           else; left out when nothing rides\n--\n-- At most HOLDS_MAX buffered (the oldest goes first); an unsent one older than\n-- HOLDS_MAX_AGE is dropped when a post is built (AUTO.presPurge).\nAUTO.presHold = function(k, color, obj, container)\n  if not broadcasting then return end\n  local P = AUTO.pres\n  if P == nil or obj == nil then return end\n  if k ~= \"up\" and k ~= \"down\" and k ~= \"out\" then return end\n  local okG, g = pcall(function() return obj.getGUID() end)\n  if not okG or type(g) ~= \"string\" or not g:match(\"^%w+$\") then return end\n  local e = { k = k, t = os.time(), g = g }\n  local finite = function(v)\n    return type(v) == \"number\" and v == v and v > -1e6 and v < 1e6\n  end\n  local okP, pos = pcall(function() return obj.getPosition() end)\n  if okP and type(pos) == \"table\" and finite(pos.x) and finite(pos.z) then\n    e.x, e.z = pos.x, pos.z\n  end\n  if k == \"out\" then\n    local okF, fg = pcall(function() return container.getGUID() end)\n    if okF and type(fg) == \"string\" and fg:match(\"^%w+$\") then e.from = fg end\n  elseif type(color) == \"string\" and color:match(\"^%a+$\") then\n    e.c = color\n    if color ~= \"Grey\" then\n      local okQ, pp = pcall(function()\n        local pl = Player[color]\n        if pl == nil or pl.seated ~= true then return nil end\n        return pl.getPointerPosition()\n      end)\n      if okQ and type(pp) == \"table\" and finite(pp.x) and finite(pp.z) then\n        e.px, e.pz = pp.x, pp.z\n      end\n    end\n  end\n  if k ~= \"out\" then\n    local okW, w = pcall(AUTO.presWith, k, color, obj, g)\n    if okW and type(w) == \"table\" and #w > 0 then e.with = w end\n  end\n  local buf = P.holds\n  if #buf >= AUTO.PRESENCE.HOLDS_MAX then table.remove(buf, 1) end\n  buf[#buf + 1] = e\nend\n\n-- THE LAST PICK-UP ACTION PER COLOUR (2026-09-29, design-5 part A), which\n-- AUTO.presWith falls back on when a picked-up object's getAttachments() cannot\n-- be read. onPlayerAction fires for EVERY action a player makes -- a box select\n-- fires once per object it adds (Event Probe run 1) -- so anything that is not\n-- a PickUp leaves after one comparison, and a PickUp costs one pcall'd guid\n-- read per target, at most WITH_MAX + 1 of them (the grabbed object, which the\n-- probe saw listed first, and eight riders), and one table write: the colour's\n-- previous record is overwritten, never appended to. Player.Action.PickUp is\n-- compared by value, as the probe did (it read 8); a TTS without it raises\n-- here, inside the handler's pcall, and records nothing.\nAUTO.presAct = function(player, action, targets)\n  if not broadcasting then return end\n  local P = AUTO.pres\n  if P == nil or type(targets) ~= \"table\" then return end\n  if action ~= Player.Action.PickUp then return end\n  local c = player.color\n  if type(c) ~= \"string\" then return end\n  local gs = {}\n  for i = 1, math.min(#targets, AUTO.PRESENCE.WITH_MAX + 1) do\n    local o = targets[i]\n    local ok, v = pcall(function() return o.getGUID() end)\n    if ok and type(v) == \"string\" then gs[#gs + 1] = v end\n  end\n  P.acts[c] = { t = os.clock(), g = gs }\nend\n\n-- =========================\n-- TABLE DRAWINGS: THE INK  (Spectator Tool Autodraw build only, 2026-09-18)\n-- =========================\n-- WHAT IT IS FOR: the pencil, line, box and circle strokes players draw ON THE\n-- TABLE. They are the last thing on the table the site could not see -- the third\n-- field of the presence layer (docs/protocol.md, \"Presence layer\" -> `drawings`),\n-- and the one that is ordinary seq'd STATE rather than an ephemeral relay.\n--\n-- WHAT THE PROBE ESTABLISHED, and every line below is shaped by it\n-- (tts/DrawingsProbe.lua ANSWERS, in game 2026-09-13):\n--   * Global.getVectorLines() hands back EVERY table stroke as a full polyline in\n--     WORLD coordinates -- `rotation` was (0,0,0) on every line ever seen, so\n--     x/z are used as they come;\n--   * with no drawings at all it returns NIL, not {} -- so nil is an EMPTY list\n--     here and never an error;\n--   * strokes are IMMUTABLE: new ones append at the END and the eraser deletes\n--     WHOLE entries, never part of one. That is what makes a fragment cache keyed\n--     on the stroke itself ~100% hits, and what makes the wire field a whole-list\n--     REPLACE rather than a merge;\n--   * a read costs about 2.7 us per point (0.52 ms at 195 points, 3.28 ms at\n--     1218) -- cheap on a scribble, not free on a mural, which is what the\n--     adaptive cadence below is for;\n--   * `color` is the INK colour, not the author -- strokes have no author and no\n--     id -- and drawings ON an object are out of scope (the pencil could not put\n--     one there).\n--\n-- WHERE IT RUNS: from pollLoop, one call, immediately below AUTO.presSample, and\n-- NEVER on the publishing tick -- `tickB` is passed in and the scanner returns on\n-- it. So the read lands on the cheaper tick and reaches the site from the next\n-- tick B, a quarter of a second later, which is exactly the reasoning THE BIG\n-- DECK ROSTER's tick-A placement is built on. Nothing per object, nothing in\n-- lightObjSig, nothing in AUTO.spreadGate or either half-scan; there are build\n-- guards on all of that.\n--\n-- THE CADENCE PAYS FOR ITSELF: every scan times its own read and asks for\n-- `round(ms * 2)` ticks before the next one, clamped to POLL_TICKS .. TICKS_MAX.\n-- A 3 ms table stays at the 2 s floor; a 27 ms mural stretches to ~13 s, so the\n-- cost of watching the ink can never exceed about half a per cent of a tick\n-- whatever anybody draws.\n--\n-- TWO SIGNATURES, and that is the whole of the change detection:\n--   TIER 1, built on every scan with no encoding at all -- the line count, the\n--     total point count, and the LAST stroke's digest. Unchanged means done, and\n--     on a settled table that is one `#` per stroke and three concats;\n--   THE PER-STROKE DIGEST, which is also the fragment cache's key: point count,\n--     thickness, colour and both endpoints. Immutable strokes make it stable, so\n--     an APPEND re-uses every earlier stroke's encoded text and pays only for the\n--     stroke that is new.\n-- Tier 1 can in principle miss a change -- erase one 40-point stroke and draw\n-- another 40-point one between two scans, with the same last stroke -- and that\n-- is accepted deliberately: the next change of any kind publishes the whole list,\n-- which is the truth as it stands then. The cheap alternative (digest every\n-- stroke on every scan) costs a walk of the whole mural to catch a case the eraser\n-- makes almost impossible.\n--\n-- THE CAP is MAX_POINTS total points on the wire (~260 KB), and over it the\n-- OLDEST whole strokes are dropped and `drawingsTruncated` rides beside the list.\n-- The newest stroke is ALWAYS kept, even alone over the cap: a wiped-looking\n-- board would be a worse answer than a big payload, and there is nothing to drop.\n-- One chat line per session says it is happening.\n--\n-- NOTHING IS WRITTEN BACK. This build never calls setVectorLines: the ink is\n-- read, and the tool draws nothing on the user's table.\nAUTO.DRAW = {\n  POLL_TICKS = 8,\n  TICKS_MAX = 60,\n  MAX_POINTS = 20000,\n}\n\n-- THE WHOLE OF THE INK STATE, built from scratch at load. It describes the TABLE\n-- (not one broadcast), which is why the room-create reset below puts back the\n-- two fields that describe a ROOM -- the sent marker and the due tick -- and\n-- keeps the rest: a fresh room's very first FULL then carries the ink the site\n-- needs immediately instead of waiting for the next scan.\n--\n--   ticks / nextTick  the adaptive cadence, in 0.25 s poll ticks\n--   lastMs            what the last read cost, which is what sets `ticks`\n--   sig               the TIER 1 signature of the last read. \"\" means \"no ink\",\n--                     and that is what keeps this out of the whole-table\n--                     signature on a table nobody has drawn on\n--   json              the encoded array, or nil for \"there is no ink\". nil is\n--                     what puts `[]` on a diff -- the ERASE -- and nothing at\n--                     all on a full\n--   truncated         whether `json` is a CAPPED list\n--   sent              the signature the room was last told; \"\" and never nil\n--   frag              the per-stroke fragment cache, digest -> encoded object.\n--                     Rebuilt on every encode from the strokes that are actually\n--                     there, so an erased stroke's text cannot outlive it and the\n--                     table is bounded by the ink on the board\n--   warned            the one truncation chat line per session\nAUTO.drawReset = function()\n  AUTO.draw = {\n    ticks = AUTO.DRAW.POLL_TICKS,\n    nextTick = 0,\n    lastMs = 0,\n    sig = \"\",\n    json = nil,\n    truncated = false,\n    sent = \"\",\n    frag = {},\n    warned = false,\n  }\nend\nAUTO.drawReset()\n\n-- A number, or nil when it is not one this build will put on the wire. THE NaN\n-- AND THE INFINITIES ARE THE POINT: string.format passes both straight through as\n-- `nan` / `inf`, which is not JSON -- a 400 on a snapshot post that the retry\n-- path can never fix, because retrying cannot make the body valid. That trap cost\n-- the presence layer a guard of its own on 2026-09-15; this is the same guard,\n-- and 1e6 is far outside any table TTS can draw on.\nAUTO.drawNum = function(v)\n  if type(v) ~= \"number\" then return nil end\n  if v ~= v or v > 1e6 or v < -1e6 then return nil end\n  return v\nend\n\n-- ONE POINT, x / y / z. The probe warns that points \"may arrive as Vector\n-- userdata or plain {x,y,z} / {1,2,3} tables\", so both spellings are read and\n-- neither is type-tested: a Vector is not a Lua table and a test for one would\n-- silently drop every stroke. Anything that raises on the index is caught by the\n-- pcall around the encode, which is where reading engine data belongs.\nAUTO.drawPt = function(p)\n  if p == nil then return nil, nil, nil end\n  return AUTO.drawNum(p.x or p[1]), AUTO.drawNum(p.y or p[2]), AUTO.drawNum(p.z or p[3])\nend\n\n-- ONE STROKE'S DIGEST: the fragment cache's key, and the last-stroke term of the\n-- tier-1 signature. Point COUNT and both ENDPOINTS rather than every point --\n-- strokes are immutable, so a stroke that kept its count, its ends, its colour\n-- and its thickness is the same stroke. Per stroke, never per point.\nAUTO.drawDigest = function(l)\n  local pts = l.points\n  local n = 0\n  if type(pts) == \"table\" then n = #pts end\n  local ax, ay, az, bx, by, bz\n  if n > 0 then\n    ax, ay, az = AUTO.drawPt(pts[1])\n    bx, by, bz = AUTO.drawPt(pts[n])\n  end\n  local c = l.color\n  local cr, cg, cb\n  if c ~= nil then\n    cr, cg, cb = AUTO.drawNum(c.r or c[1]), AUTO.drawNum(c.g or c[2]), AUTO.drawNum(c.b or c[3])\n  end\n  return tostring(n) .. \"|\" .. tostring(AUTO.drawNum(l.thickness))\n    .. \"|\" .. tostring(cr) .. \",\" .. tostring(cg) .. \",\" .. tostring(cb)\n    .. \"|\" .. tostring(ax) .. \",\" .. tostring(ay) .. \",\" .. tostring(az)\n    .. \"|\" .. tostring(bx) .. \",\" .. tostring(by) .. \",\" .. tostring(bz)\nend\n\n-- TIER 1, and the only thing a settled table ever pays: the count, the total\n-- point count and the newest stroke's digest. \"\" for an empty table, which is\n-- what keeps the ink out of AUTO.spreadSignature until somebody draws.\nAUTO.drawSig = function(lines)\n  local n = #lines\n  if n == 0 then return \"\" end\n  local total = 0\n  for i = 1, n do\n    local pts = lines[i].points\n    if type(pts) == \"table\" then total = total + #pts end\n  end\n  return tostring(n) .. \":\" .. tostring(total) .. \":\" .. AUTO.drawDigest(lines[n])\nend\n\n-- ONE STROKE, ENCODED, in the shape docs/protocol.md froze: colour channels at 3\n-- decimals, coordinates at 2, `y` ONCE per stroke (it is table height and is\n-- constant along a stroke -- the 2D site ignores it, a 3D viewer will not), and\n-- `points` the FLAT interleaved [x1,z1,x2,z2,...] the site reads.\n--\n-- BUILT BY HAND rather than through jencTimed: a stroke is 40 to 1200 points and\n-- a fixed format per number is both smaller and the only form that cannot emit\n-- something that is not JSON. thickness gets the colour's 3 decimals rather than\n-- going out raw for the same reason -- a float32 pen width widened to a double\n-- prints as 0.10000000149012, which is valid and ugly, and 3 decimals is finer\n-- than any TTS pen.\n--\n-- A stroke with a coordinate this build will not send is DROPPED WHOLE (nil), not\n-- half-sent: half a stroke is a line across the table that nobody drew. A colour\n-- or a thickness that cannot be read is defaulted instead, because neither\n-- decides where the ink IS.\nAUTO.drawFrag = function(l)\n  local pts = l.points\n  if type(pts) ~= \"table\" then return nil end\n  local n = #pts\n  if n == 0 then return nil end\n  local parts, y = {}, nil\n  for i = 1, n do\n    local x, py, z = AUTO.drawPt(pts[i])\n    if x == nil or z == nil then return nil end\n    parts[i] = string.format(\"%.2f,%.2f\", x, z)\n    if y == nil then y = py end\n  end\n  local c = l.color\n  local cr, cg, cb = 1, 1, 1\n  if c ~= nil then\n    cr = AUTO.drawNum(c.r or c[1]) or 1\n    cg = AUTO.drawNum(c.g or c[2]) or 1\n    cb = AUTO.drawNum(c.b or c[3]) or 1\n  end\n  local th = AUTO.drawNum(l.thickness) or 0\n  return '{\"color\":{\"r\":' .. string.format(\"%.3f\", cr)\n    .. ',\"g\":' .. string.format(\"%.3f\", cg)\n    .. ',\"b\":' .. string.format(\"%.3f\", cb)\n    .. '},\"thickness\":' .. string.format(\"%.3f\", th)\n    .. ',\"y\":' .. string.format(\"%.2f\", y or 0)\n    .. ',\"points\":[' .. table.concat(parts, \",\") .. ']}'\nend\n\n-- THE WHOLE LIST, and the cap. Returns the encoded array and whether anything was\n-- dropped -- or nil, which is \"there is no ink\", the answer that puts `[]` on the\n-- next diff.\n--\n-- THE CAP DROPS THE OLDEST, walking back from the newest and stopping at the\n-- first stroke that will not fit, because TTS appends and the newest strokes are\n-- the ones somebody is looking at. THE NEWEST IS ALWAYS KEPT, alone over the cap\n-- and all: there is nothing older to drop, and an empty board would be a worse\n-- answer than a big payload. ORDER IS PRESERVED end to end -- later strokes are\n-- drawn ON TOP at the site.\n--\n-- THE FRAGMENT CACHE is rebuilt into a fresh table as the list is walked: every\n-- stroke still on the board carries its text over from the old one (immutable, so\n-- ~100% hits on an append), and an erased stroke's text is simply not carried, so\n-- the cache is always exactly the ink on the board and can never grow without\n-- bound.\nAUTO.drawEncode = function(lines)\n  local C = AUTO.DRAW\n  local D = AUTO.draw\n  local n = #lines\n  local first, acc, trunc = 1, 0, false\n  for i = n, 1, -1 do\n    local pts = lines[i].points\n    local cnt = 0\n    if type(pts) == \"table\" then cnt = #pts end\n    if i < n and (acc + cnt) > C.MAX_POINTS then\n      trunc = true\n      break\n    end\n    acc = acc + cnt\n    first = i\n  end\n  local old, frag = D.frag, {}\n  local out, on = {}, 0\n  for i = first, n do\n    local l = lines[i]\n    local key = AUTO.drawDigest(l)\n    local s = old[key]\n    if s == nil then s = AUTO.drawFrag(l) end\n    if s ~= nil then\n      frag[key] = s\n      on = on + 1\n      out[on] = s\n    end\n  end\n  D.frag = frag\n  if on == 0 then return nil, trunc end\n  return \"[\" .. table.concat(out, \",\") .. \"]\", trunc\nend\n\n-- THE SCAN, from pollLoop and nowhere else.\n--\n-- NEVER ON THE PUBLISHING TICK: `tickB` is the tick that assembles the\n-- whole-table signature and posts, so the read happens on the other one and its\n-- finding is published a quarter of a second later, off the tick that pays for\n-- the post.\n--\n-- ITS OWN COUNTER, in ticks rather than seconds, so the cadence is the same\n-- number the profile prints -- which is why `now` is taken and not read, exactly\n-- as AUTO.presSample takes it: the call site reads the same as the other two\n-- per-tick samplers beside it. The due tick is set from the tick the scan RAN on,\n-- and because this only ever runs on a non-publishing tick an ODD interval simply\n-- lands on the next one of those -- at most one tick later than asked, and never\n-- earlier.\n--\n-- NIL IS EMPTY (probe D1), a read that RAISES is empty too, and both the\n-- signature and the encode are pcall'd: this is engine data, read once every two\n-- seconds or more, and nothing about it may cost the tick. A failed encode leaves\n-- the signature ALONE, so the next scan tries the same list again instead of\n-- recording a list that was never encoded.\nAUTO.drawScan = function(now, tickB)\n  if tickB then return end\n  local D = AUTO.draw\n  if D == nil then return end\n  local C = AUTO.DRAW\n  local tick = AUTO.tick or 0\n  if tick < (D.nextTick or 0) then return end\n  local t0 = os.clock()\n  local okR, lines = pcall(function() return Global.getVectorLines() end)\n  local dt = os.clock() - t0\n  if (not okR) or type(lines) ~= \"table\" then lines = {} end\n  D.lastMs = dt * 1000.0\n  local t = math.floor((D.lastMs * 2.0) + 0.5)\n  if t < C.POLL_TICKS then t = C.POLL_TICKS end\n  if t > C.TICKS_MAX then t = C.TICKS_MAX end\n  D.ticks = t\n  D.nextTick = tick + t\n  if DEBUG_ENABLED then\n    PROF.drawScans = PROF.drawScans + 1\n    PROF.drawScanMs = PROF.drawScanMs + D.lastMs\n  end\n  local okS, sig = pcall(AUTO.drawSig, lines)\n  if not okS then return end\n  if sig == D.sig then return end\n  local okE, json, trunc = pcall(AUTO.drawEncode, lines)\n  if not okE then return end\n  D.sig = sig\n  D.json = json\n  D.truncated = trunc and true or false\n  if D.truncated and not D.warned then\n    D.warned = true\n    print(\"[Spectator] The table's ink is over \" .. tostring(C.MAX_POINTS) ..\n          \" points: the OLDEST strokes are not being sent.\")\n  end\nend\n\n-- The top-level `drawings` field, in the shape UI_ASSETS.payloadField and\n-- AUTO.handZonesField answer: the JSON fragment to splice into the payload, or \"\"\n-- for \"say nothing this time\".\n--\n-- WHEN IT IS SENT:\n--   * every FULL that has ink. A full re-baselines and an ABSENT field there\n--     means \"no drawings\" (stateFromFull, not applyDiff), so a full is also the\n--     repair path -- a diff that never reached the server took its ink with it,\n--     and the 409 that earns forces exactly this;\n--   * a DIFF only when the ink has CHANGED since the room was last told. On a\n--     settled table that is never: one string compare and nothing else.\n--\n-- `[]` IS A REAL ANSWER ON A DIFF and the one asymmetry here -- the ERASE. On a\n-- diff an absent field means \"unchanged\", so a wiped table has to be TOLD, and\n-- `[]` is how. It is written out rather than encoded because an empty Lua table\n-- has no unambiguous encoding (js/applyDiff.js counts an ARRAY and only an array\n-- as \"the diff carries one\", precisely so `[]` cannot read as absent). It can only\n-- happen when the room was told something else before -- the marker starts \"\" --\n-- so a table nobody has ever drawn on never mentions the field at all.\n--\n-- IT STAMPS THE INK AS SENT, exactly as the asset table and the hand zones do,\n-- and it is safe for the same reason: buildDiffSnapshot hoists this into a local\n-- and counts it in the empty-diff verdict, so a payload carrying it is never a\n-- payload that gets skipped. A post that FAILS re-sends as a full, and a full\n-- carries the list whatever the marker says -- or, for an ERASE, carries nothing,\n-- which on a full is the same news.\nAUTO.drawingsField = function(isFull)\n  local D = AUTO.draw\n  if D == nil then return \"\" end\n  local json = D.json\n  local sig = D.sig\n  if json == nil then\n    if isFull or sig == D.sent then return \"\" end\n    D.sent = sig\n    if DEBUG_ENABLED then PROF.drawPublishes = PROF.drawPublishes + 1 end\n    return ',\"drawings\":[]'\n  end\n  if (not isFull) and sig == D.sent then return \"\" end\n  D.sent = sig\n  if DEBUG_ENABLED then PROF.drawPublishes = PROF.drawPublishes + 1 end\n  if D.truncated then\n    return ',\"drawings\":' .. json .. ',\"drawingsTruncated\":true'\n  end\n  return ',\"drawings\":' .. json\nend\n\n-- =========================\n-- GREY OUT OF HANDS  (Spectator Tool Autodraw build only, 2026-09-15)\n-- =========================\n-- The seated players the WIRE is built from: Player.getPlayers() with the host\n-- gone Grey dropped. Grey is returned as a player entry with seated == true and\n-- no hand (Seat Probe, 2026-09-15), so every list built from the raw walk carried\n-- an empty \"Grey Hand\" for a seat nobody is sitting in. Black, the GM seat, is a\n-- real seat and is NOT filtered -- it holds cards and the site draws them.\n--\n-- Used by the five readers of the player list that feed the wire or a hand\n-- signature, and by nothing else: the round-robin rebuild and AUTO.refreshExcl\n-- walk HANDS (an empty one costs nothing, and keeping a spectator's cards off the\n-- public site is right whoever holds them), and the presence sampler skips Grey\n-- by colour itself, ahead of its pointer read.\n--\n-- The walk is deliberately unguarded, as it is at every call site this replaces:\n-- a failure here means a payload that could not be built, which is what those\n-- callers already answer for. The per-player colour read IS guarded -- a seat\n-- that left between the walk and the read is the one raise this can meet -- and\n-- an unreadable colour keeps the player rather than losing a real seat's hand.\nAUTO.seatedPlayers = function()\n  local all = Player.getPlayers()\n  if type(all) ~= \"table\" then return {} end\n  local out, n = {}, 0\n  for i = 1, #all do\n    local p = all[i]\n    local okC, c = pcall(function() return p.color end)\n    if okC and c ~= \"Grey\" then\n      n = n + 1\n      out[n] = p\n    end\n  end\n  return out\nend\n\n\n-- =========================\n-- HAND ZONES ON THE BOARD  (Spectator Tool Autodraw build only, 2026-09-18)\n-- =========================\n-- WHAT THE USER ASKED FOR, in his words: \"lets make it possible to see hand\n-- zones that are in the zones already. as a toggle that is default on.\"\n--\n-- A TTS hand zone lies INSIDE the auto zone -- which is fitted to the whole\n-- table, so EVERY hand zone is inside it -- and the site drew nothing of it: no\n-- box, and no cards, because AUTO.refreshExcl kept every hand card off the wire\n-- (see WHAT MUST NEVER BE PUBLISHED above). A hand zone as TTS draws one from\n-- above is the coloured box AND the cards in it, so the feature is two halves:\n--\n--   * the GEOMETRY of every hand zone, read here and sent as the new top-level\n--     `handZones` field (docs/protocol.md, \"Hand zones\");\n--   * the CARDS, which AUTO.refreshExcl stops excluding while this switch and\n--     REVEAL_HIDDEN are both on -- and only then, because a hand card is face UP\n--     to its owner and nothing would redact it.\n--\n-- WHAT IT COSTS, and the whole design is about this: ONE walk of the SEAT\n-- COLOURS -- at most ten of them, one getHandCount each plus one\n-- getHandTransform per hand zone -- inside the zone cache's own rescan branch,\n-- which happens every ZONE_RESCAN_SECONDS (10 s) and on force. Nothing per\n-- object, nothing per tick, nothing in lightObjSig and nothing in\n-- AUTO.spreadGate. The publish decision reads the SIGNATURE this leaves behind,\n-- which is a string compare (AUTO.spreadSignature).\n--\n-- NOT PERSISTED, exactly like the Hot ticks switch: a table saved with it off must\n-- not ship a different tool to the next session, so every load starts it true.\nAUTO.HAND_ZONES = true\n\n-- What the read leaves behind, and the marker that says what the ROOM has been\n-- told:\n--   handZones     -- the list the wire carries, or nil for \"nothing to say\" (no\n--                    hand zone anywhere, or the switch is off)\n--   handZonesSig  -- the same numbers as a plain string; this is what moves the\n--                    whole-table signature and what the diff compares\n--   handZonesSent -- the signature the room was last told. \"\" and never nil, so\n--                    a table with no hand zones at all never puts an empty list\n--                    on the wire; cleared back to \"\" at room create and whenever\n--                    the switch is pressed.\nAUTO.handZones = nil\nAUTO.handZonesSig = \"\"\nAUTO.handZonesSent = \"\"\n\n-- ONE read, from the zone cache's rescan and nowhere else.\n--\n-- THE COLOURS come from Player.getColors(), which answers every seat colour the\n-- table HAS -- seated or not -- rather than only the occupied ones: an empty\n-- seat's hand zone exists and is worth drawing, and Player.White.getHandCount()\n-- answered 1 for an empty seat in the Seat Probe (2026-09-15). No union with\n-- getAvailableColors() is needed or made. Grey and Black are skipped by NAME:\n-- Grey is the unseated host, Black the GM seat, and neither has a hand.\n--\n-- THE TRAP, and the reason this function is shaped the way it is:\n-- getHandObjects() on a seat whose hand count is 0 raises a .NET\n-- NullReferenceException that pcall does NOT catch -- it killed the whole\n-- callback twice on 2026-09-15 (Black, and the host gone Grey). So this function\n-- never calls it at all, and it asks getHandTransform ONLY under a count above\n-- zero. Every engine read is pcall'd, and a colour whose count cannot be read is\n-- skipped rather than fatal.\n--\n-- THE ORDER IS COLOUR NAME, THEN INDEX, and the colour list is SORTED to get it:\n-- the change test is a plain string compare, so the same set of hand zones has\n-- to produce the same string however the engine happens to list the seats.\n--\n-- ROUNDED LIKE A ZONE, by the same two helpers with the same two constants, so\n-- a hand zone and a scripting zone describe themselves identically on the wire.\n-- The signature carries every one of those nine numbers rather than the five the\n-- 2D site reads: the wire sends all three axes for a future 3D viewer, and a\n-- signature that ignored four of them would let a change reach the site never.\nAUTO.handZonesRead = function()\n  local t0 = os.clock()\n  AUTO.handZones = nil\n  AUTO.handZonesSig = \"\"\n  if AUTO.HAND_ZONES then\n    local okC, colors = pcall(function() return Player.getColors() end)\n    if okC and type(colors) == \"table\" then\n      local seats, sn = {}, 0\n      for i = 1, #colors do\n        local c = colors[i]\n        if type(c) == \"string\" and c ~= \"Grey\" and c ~= \"Black\" then\n          sn = sn + 1\n          seats[sn] = c\n        end\n      end\n      table.sort(seats)\n      local out, parts, n = {}, {}, 0\n      for si = 1, sn do\n        local c = seats[si]\n        local okN, hc = pcall(function() return Player[c].getHandCount() end)\n        if okN and type(hc) == \"number\" and hc > 0 then\n          for i = 1, hc do\n            -- NEVER on a count of 0 -- see THE TRAP above. The count said there\n            -- are hands, so asking each of them where it is, is safe.\n            local okT, ht = pcall(function() return Player[c].getHandTransform(i) end)\n            if okT and type(ht) == \"table\" then\n              local p = vec3Round(ht.position, POS_DECIMALS)\n              local r = rotRound(ht.rotation, ROT_DECIMALS)\n              local sc = vec3Round(ht.scale, POS_DECIMALS)\n              n = n + 1\n              out[n] = { color = c, idx = i, pos = p, rot = r, scale = sc }\n              parts[n] = c .. \"/\" .. tostring(i) .. \":\"\n                .. tostring(p.x) .. \",\" .. tostring(p.y) .. \",\" .. tostring(p.z)\n                .. \"|\" .. tostring(r.x) .. \",\" .. tostring(r.y) .. \",\" .. tostring(r.z)\n                .. \"|\" .. tostring(sc.x) .. \",\" .. tostring(sc.y) .. \",\" .. tostring(sc.z)\n            end\n          end\n        end\n      end\n      if n > 0 then AUTO.handZones = out end\n      AUTO.handZonesSig = table.concat(parts, \";\")\n    end\n  end\n  if DEBUG_ENABLED then profAdd(\"t_handzones\", os.clock() - t0) end\nend\n\nlocal function rrStepButtons()\n  if #RR_OBJECTS == 0 then return end\n  local now = os.clock()\n  -- Cap at the object count so small games don't getButtons() the same object\n  -- more than once per tick. RR IS the refresh schedule here.\n  local budget = math.min(RR_BTN_BUDGET, #RR_OBJECTS)\n  for _ = 1, budget do\n    local i\n    i, rrObjIdx = rrNextIdx(RR_META.guids, \"visited\", rrObjIdx)\n    if not i then break end  -- every entry dead until the next rebuild\n    local o = RR_OBJECTS[i]\n    local dg = RR_META.guids[i]\n    RR_META.visited[dg] = true\n\n    if o and o.getGUID then\n      local g = safeStr(o.getGUID())\n      if g ~= \"\" then\n        local e = BTN_CACHE[g]\n        if not e then\n          e = { buttons=nil, hasButtons=false, nextAt=0 }\n          BTN_CACHE[g] = e\n        end\n        -- Unconditional re-read (ignore e.nextAt): the RR budget already paces\n        -- how often each object is refreshed, so stationary objects' label\n        -- changes reach the cache and, via buttonsSig, the signatures.\n        refreshButtonsEntry(o, e, now)\n        if DEBUG_ENABLED then PROF.rrBtnRefreshes = PROF.rrBtnRefreshes + 1 end\n      end\n    end\n  end\nend\n\nlocal function rrStepPeeks()\n  if #RR_CONTAINERS == 0 then return end\n  for _ = 1, RR_PEEK_BUDGET do\n    local i\n    i, rrContIdx = rrNextIdx(RR_META.contGuids, \"contVisited\", rrContIdx)\n    if not i then break end  -- every entry dead until the next rebuild\n    local o = RR_CONTAINERS[i]\n    local dg = RR_META.contGuids[i]\n    RR_META.contVisited[dg] = true\n\n    if o and o.getGUID then\n      local g = safeStr(o.getGUID())\n      if g ~= \"\" then\n        local e = PEEK_CACHE[g]\n        if not e then\n          e = { peek=nil, nextAt=0 }\n          PEEK_CACHE[g] = e\n        end\n        e.nextAt = 0\n        if DEBUG_ENABLED then PROF.rrPeekInvalidations = PROF.rrPeekInvalidations + 1 end\n      end\n    end\n  end\nend\n\n-- =========================\n-- SERIALIZATION (unchanged)\n-- =========================\n-- =========================\n-- SPECIAL ASSET REGISTRY\n-- =========================\n-- A few objects build their visible face by means the TTS API does not expose, so\n-- nothing in the normal payload describes what they actually look like. The first\n-- case: a Custom_Tile whose colour, ring and symbol are XML UI images pulled from a\n-- Unity AssetBundle -- getCustomObject() reports only the blank grey disc underneath.\n-- The site ships its own copy of that artwork and only needs to be told WHICH special\n-- object this is, plus whatever selects the art. tracked-assets.xlsx registers every\n-- such asset, why the normal path cannot get it, and how to re-verify it.\n--\n-- IDENTIFICATION ORDER: URL first, memo second. A Steam UGC URL is content-addressed\n-- (the address is a hash of that exact upload), so it cannot false-positive, but it\n-- breaks silently when a mod re-uploads. memo survives a re-upload but is weaker.\n-- Trying URL then memo gets the strength of one and the resilience of the other.\n-- Displayed names are NEVER used for identity: these objects rename themselves.\n--\n-- COST RULE: nothing here may enter lightObjSig, which runs for every object every\n-- POLL_SECONDS. specialFor() is called once per fragment BUILD, and fragments live\n-- FRAG_TTL_SECONDS, so an object is tested a few times per tens of minutes. For an\n-- ordinary object the entire test is ONE failed hash lookup on its tag -- the memo\n-- read never happens for a card or a deck. Tags are deliberately absent here:\n-- hasTag() is a real API call and does not belong on this path.\nlocal SPECIAL = {\n  byUrl = {}, byMemo = {}, objTags = {},   -- indexes, derived from `assets` at load\n  fallbackLogAt = 0,\n  assets = {\n    -- Arkham Horror LCG - Super Complete Edition 4.7.0, \"Universal Action / Ability\n    -- Token\". changesWith documents what makes its signature move: picking a class or\n    -- symbol calls setName AND setScale, both already digested into lightObjSig via\n    -- rrMetaSig, so the fragment re-encodes on its own with nothing added here.\n    -- EVERY new entry must state this, or its extra payload goes stale in FRAG_CACHE.\n    utoken = {\n      objTag      = \"Tile\",\n      changesWith = \"name+scale (setName/setScale, already in rrMetaSig)\",\n      urls  = { [\"https://steamusercontent-a.akamaihd.net/ugc/2447222612020428918/898E79CD6752EE225ED8563EBCFFC09FF4566EE2/\"] = true },\n      memos = { universalActionAbility = true },\n      classes = { Guardian=true, Mystic=true, Neutral=true, Rogue=true, Seeker=true, Survivor=true },\n      symbols = {\n        Activate=true, Engage=true, Evade=true, Explore=true, Fight=true, FreeTrigger=true,\n        Investigate=true, Move=true, None=true, Parley=true, PlayItem=true, Reaction=true,\n        Resource=true, Scan=true, Spell=true, Tome=true,\n        Guardian=true, Mystic=true, Neutral=true, Rogue=true, Seeker=true, Survivor=true,\n      },\n      -- script_state is authoritative: {\"class\":\"Rogue\",\"symbol\":\"Guardian\"}. The name\n      -- cannot always reconstruct that pair -- when the symbol IS a class name the\n      -- token names itself after the CLASS alone and the symbol is lost -- so the name\n      -- is only a fallback for when script_state is unreadable.\n      enrich = function(e, item, obj)\n        local cls, sym\n        local okS, st = pcall(function() return obj.script_state end)\n        if okS and type(st) == \"string\" and st ~= \"\" then\n          local okD, dec = pcall(function() return JSON.decode(st) end)\n          if okD and type(dec) == \"table\" then cls, sym = dec.class, dec.symbol end\n        end\n        if type(cls) ~= \"string\" or cls == \"\" then\n          local n = safeStr(item.name)\n          local a, b = string.match(n, \"^(%S+)%s+(.+)$\")\n          if a then cls, sym = a, b else cls, sym = n, n end\n        end\n        cls = safeStr(cls)\n        sym = string.gsub(safeStr(sym), \"Ability\", \"\")\n        if not e.classes[cls] then return end\n        -- \"A/B\" draws two half-size symbols; validate each half so one unknown\n        -- name cannot smuggle a bad filename through to the site.\n        for part in string.gmatch(sym, \"([^/]+)\") do\n          if not e.symbols[part] then return end\n        end\n        if sym == \"\" then return end\n        item.uToken = { cls = cls, sym = sym }\n      end,\n    },\n  },\n}\nfor id, e in pairs(SPECIAL.assets) do\n  e.id = id\n  SPECIAL.objTags[e.objTag] = true\n  for u in pairs(e.urls  or {}) do SPECIAL.byUrl[u]  = e end\n  for m in pairs(e.memos or {}) do SPECIAL.byMemo[m] = e end\nend\n\n-- Returns (entry, matchedByUrl) or nil. Ordered cheapest-test-first; each rejects.\nlocal function specialFor(obj, tag, imgUrl)\n  if not SPECIAL.objTags[tag] then return nil end          -- rejects nearly everything\n  if imgUrl then\n    local e = SPECIAL.byUrl[imgUrl]\n    if e then return e, true end                           -- free: imgUrl already fetched\n  end\n  local okM, memo = pcall(function() return obj.memo end)\n  if okM and type(memo) == \"string\" and memo ~= \"\" then\n    local e = SPECIAL.byMemo[memo]\n    if e then return e, false end\n  end\n  return nil\nend\n\nlocal function itemForObject(obj, staleOk)\n  if not obj then return nil end\n\n  local tag = safeStr(obj.tag)\n  local guid = safeStr(obj.getGUID())\n  local name = safeName(obj)\n\n  local pos = vec3Round(obj.getPosition(), POS_DECIMALS)\n  local rot = rotRound(obj.getRotation(), ROT_DECIMALS)\n\n  local item = {\n    guid = guid,\n    tag = tag,\n    name = name,\n    pos = { x = pos.x, y = pos.y, z = pos.z },\n    rot = { x = rot.x, y = rot.y, z = rot.z },\n    scale = objScaleRound(obj, POS_DECIMALS),\n    bounds = objBoundsRound(obj, POS_DECIMALS),\n  }\n\n  item.btnSpace = computeButtonSpaceAspect(obj)\n\n  local cval = tryGetCounterValue(obj)\n  if cval ~= nil then item.counter = { value = cval, anchor = tryCounterTextAnchor(obj) } end\n\n  item.tint = tryGetTint(obj)\n  -- Only ever true; false is left nil so the field costs nothing for the\n  -- overwhelming majority of objects, which are visible.\n  item.invis = tryIsInvisible(obj) or nil\n  -- XML UI capture (Spectator 13 XML build only)\n  item.xmlRaw = tryGetXmlRaw(obj)\n  if item.xml or item.xmlRaw then item.uiAssets = tryGetUiAssets(obj) end\n  item.kind = tryGetKind(obj)\n  -- VISUAL BOUNDS for a NATIVE object (Spectator Tool Autodraw build only,\n  -- 2026-09-14). `bounds` has always been objBoundsRound's getBoundsNormalized()\n  -- -- the merged COLLIDERS -- and for a built-in object that is often not the\n  -- shape anybody sees: Tileset_Tree's collider is the 1x1 box around its trunk\n  -- while its renderers are the whole canopy, so the site drew the tree at a\n  -- fraction of its real footprint. getVisualBoundsNormalized() is the merged\n  -- RENDERERS, the same \"as if the object were rotated to 0,0,0\" box, so it\n  -- drops straight into the same field and no new field rides the wire.\n  --\n  -- NATIVE ONLY, which is exactly what `kind` being a string means (tryGetKind\n  -- answers nil for Custom_*). A custom object keeps its collider bounds because\n  -- its images were calibrated against them, and moving the field would move\n  -- every custom drawing on the board at once. 3DText is out too: it is spliced\n  -- into its zone by GUID, has no collider for the old call either, and the site\n  -- sizes it from its font rather than from this.\n  --\n  -- COST: one engine read per FRAGMENT BUILD, on the path that has just paid for\n  -- getBoundsNormalized and a dozen other reads. Nothing is added to the\n  -- per-object per-tick scan or to lightObjSig. pcall'd, and any failure leaves\n  -- today's collider answer exactly where objBoundsRound put it.\n  if type(item.kind) == \"string\" and item.tag ~= \"3DText\" then\n    local okVB, vb = pcall(function() return obj.getVisualBoundsNormalized() end)\n    if okVB and vb and vb.size then item.bounds = vec3Round(vb.size, POS_DECIMALS) end\n  end\n  if item.tint == nil and item.kind ~= nil and item.tag ~= \"Card\" and item.tag ~= \"Deck\"\n     and not (item.face or item.front or item.back or item.img or item.img_front or item.img_back) then\n    item.tint = tryGetWideTint(obj)\n  end\n  item.meshIdx = tryGetMeshIndex(obj, item.tag)\n  if item.kind == \"3DText\" then item.text = tryGetText(obj) end\n\n  local btns = extractButtonsCached(obj, staleOk)\n  if btns then item.buttons = btns end\n\n  -- Inputs and decals (Spectator Tool Autodraw build only): pure BTN_CACHE\n  -- reads. extractButtonsCached above has just guaranteed the entry exists and\n  -- is populated, so nothing here can trigger an engine call. The empty-GUID\n  -- test is not paranoia: BTN_CACHE[\"\"] is a real entry that ensureMetaSeeded\n  -- creates for any object whose getGUID answers nothing, and it is SHARED by\n  -- every such object -- so reading it here would put one object's typed text on\n  -- another. extractButtonsCached sidesteps the same cache entry for buttons.\n  local ioe = (guid ~= \"\") and BTN_CACHE[guid] or nil\n  if ioe then\n    item.inputs = ioe.inputs\n    item.decals = ioe.decals\n    -- The notecard's body (Spectator Tool Autodraw build only, 2026-09-14):\n    -- another pure cache read off the entry already in hand. Empty string for a\n    -- notecard with no description, nil -- and so absent from the wire -- for\n    -- every object that is not one.\n    item.desc = ioe.desc\n  end\n\n  if tag == \"Card\" or tag == \"Deck\" then item.faceDown = isFaceDown(obj) end\n\n  -- Custom-mesh URL for the client-side top-down mesh renderer. Placed before\n  -- the tag branches so it applies to ALL tags (Infinite, Generic, Figurine,\n  -- Bag, ...), each of which returns from its own branch below.\n  local mu = tryGetMeshUrl(obj)\n  if mu then item.mesh = mu end\n\n  if tag == \"Card\" then\n    -- Hidden mode: a face-down card leaks nothing but its back.\n    local hidden = item.faceDown and (not REVEAL_HIDDEN)\n    local front, back, w, h, idx, ub = tryCustomDeckImagesForCard(obj)\n    if front or back then\n      item.back = (back and back ~= \"\") and back or nil\n      item.w = w; item.h = h; item.i = idx\n      if ub and item.back then item.backIsSheet = true end\n      if hidden then\n        item.isBack = true\n      else\n        item.front = (front and front ~= \"\") and front or nil\n        if item.faceDown then\n          item.face = item.back or item.front\n          item.isBack = (item.face == item.back)\n        else\n          item.face = item.front or item.back\n          item.isBack = (item.face == item.back)\n        end\n      end\n    elseif not hidden then\n      local img = trySingleImage(obj)\n      if img then item.img = img end\n    end\n    if hidden then\n      -- keep guid, tag, transform, back; drop everything face-revealing\n      item.name = nil\n      item.front = nil\n      item.face = nil\n      item.img = nil\n    end\n    return item\n  end\n\n  if tag == \"Deck\" then\n    local pk = containerPeekCached(obj, staleOk)\n    if REVEAL_HIDDEN then\n      item.peek = pk\n    else\n      item.peek = { total = (pk and pk.total) or 0, shown = 0, hidden = true, items = {} }\n    end\n    local prev = deckPreviewSprite(obj)\n    -- A BIG DECK STILL HAS A TOP CARD (Spectator Tool Autodraw build only,\n    -- 2026-09-17). Over AUTO.BIG_CONTAINER_LIMIT items nothing is serialised,\n    -- so the line above answers nil and this deck -- 399 cards on the user's\n    -- table -- drew as a placeholder. Two fallbacks, in this order:\n    --\n    --   * THE ROSTER (AUTO.rosterTop) -- the top card's real face, or its real\n    --     back, out of a per-deck list of card numbers kept up to date by ONE\n    --     getData per change burst on a quiet tick. It answers nil until that\n    --     first read lands, which is at most AUTO.ROSTER_CAP_WAIT plus a tick\n    --     after the deck is first built.\n    --   * THE SHARED BACK (AUTO.bigDeckBack) -- one getCustomObject, half a\n    --     millisecond, no card id needed, so a FACE-DOWN deck shows something\n    --     right away and goes on doing so if the roster never fills.\n    --\n    -- The size test is a lookup on the memo this fragment build has open, not\n    -- an engine call, and it is asked ONCE for both fallbacks.\n    if prev == nil and AUTO.bigContainer(obj, AUTO.dataMemo) then\n      prev = AUTO.rosterTop(obj, item.faceDown)\n      if prev == nil and item.faceDown then prev = AUTO.bigDeckBack(obj) end\n    end\n    if prev then\n      local hasSheet = (type(prev.w) == \"number\" and type(prev.h) == \"number\" and type(prev.i) == \"number\")\n      if hasSheet then\n        item.preview = { kind=\"sprite\", face=prev.url, w=prev.w, h=prev.h, i=prev.i, isBack=prev.isBack, backIsSheet=prev.backIsSheet, cardID=prev.cardID }\n      else\n        item.preview = { kind=\"single\", face=prev.url, isBack=prev.isBack, cardID=prev.cardID }\n      end\n    end\n    if not item.preview then\n      local img = trySingleImage(obj)\n      if img then item.img = img end\n    end\n    return item\n  end\n\n  if tag == \"Bag\" or tag == \"InfiniteBag\" then\n    local pk = containerPeekCached(obj, staleOk)\n    if REVEAL_HIDDEN then\n      item.peek = pk\n    else\n      item.peek = { total = (pk and pk.total) or 0, shown = 0, hidden = true, items = {} }\n    end\n    local img = trySingleImage(obj)\n    if img then item.img = img end\n    if not item.img then\n      -- Imageless bag: borrow the first contained object's front image.\n      local front = tryBagFirstContainedFront(obj)\n      if front then item.img_front = front end\n    end\n    return item\n  end\n\n  do\n    local f, b = tryFrontBackImages(obj)\n    if f then item.img_front = f end\n    if b then item.img_back  = b end\n    if (not item.img_front) and (not item.img_back) then\n      local img = trySingleImage(obj)\n      if img then item.img = img end\n    end\n    -- Infinite bags report tag == \"Infinite\" and fall through here (NOT the Bag\n    -- branch above). If still imageless, borrow the first contained object's\n    -- front image.\n    if (tag == \"Infinite\" or tag == \"Bag\") and (not item.img) and (not item.img_front) and (not item.img_back) then\n      local front = tryBagFirstContainedFront(obj)\n      if front then item.img_front = front end\n    end\n  end\n\n  return item\nend\n\nlocal function snapshotZones()\n  local zones = refreshZoneCacheIfNeeded(false)\n  local out = {}\n\n  for _, z in ipairs(zones) do\n    -- Dead reference: leave this zone out of the snapshot entirely (zoneAlive\n    -- already flagged the cache for rebuild). A full that omits a deleted zone\n    -- is correct -- the site drops what the payload no longer lists.\n    if zoneAlive(z) then\n      local zname = safeStr(z.getName())\n      local label = zoneLabelFromName(zname)\n\n      local pos = vec3Round(z.getPosition(), POS_DECIMALS)\n      local rot = rotRound(z.getRotation(), ROT_DECIMALS)\n\n      local scale = nil\n      do\n        local okS, sc = pcall(function() return z.getScale() end)\n        if okS and sc then\n          local sc2 = vec3Round(sc, POS_DECIMALS)\n          scale = { x = sc2.x, y = sc2.y, z = sc2.z }\n        end\n      end\n\n      local items = {}\n      local ok, objs = pcall(function() return AUTO.filtered(z) end)\n      if ok and type(objs) == \"table\" then\n        if DEBUG_ENABLED then\n          PROF.zones = PROF.zones + 1\n          PROF.zoneObjs = PROF.zoneObjs + (#objs or 0)\n        end\n        for _, obj in ipairs(objs) do\n          local it = itemForObject(obj, true)  -- full path: stale-OK buttons/peeks\n          if it then table.insert(items, it) end\n        end\n      else\n        if DEBUG_ENABLED then PROF.zones = PROF.zones + 1 end\n      end\n\n      table.sort(items, function(a, b) return tostring(a.guid or \"\") < tostring(b.guid or \"\") end)\n\n      table.insert(out, {\n        guid = safeStr(z.getGUID()),\n        name = zname,\n        label = label,\n        pos = { x = pos.x, y = pos.y, z = pos.z },\n        rot = { x = rot.x, y = rot.y, z = rot.z },\n        scale = scale,\n        objects = items,\n      })\n    end\n  end\n\n  return out\nend\n\n-- =========================\n-- HAND SNAPSHOT\n-- =========================\nlocal function itemForHandObj(obj, staleOk)\n  if not obj then return { name = \"(nil)\" } end\n\n  local tag = safeStr(obj.tag)\n  local it = {\n    guid = safeStr(obj.getGUID()),\n    tag = tag,\n    name = safeName(obj),\n    scale = objScaleRound(obj, POS_DECIMALS),\n    bounds = objBoundsRound(obj, POS_DECIMALS),\n  }\n\n  it.btnSpace = computeButtonSpaceAspect(obj)\n\n  local cval = tryGetCounterValue(obj)\n  if cval ~= nil then it.counter = { value = cval, anchor = tryCounterTextAnchor(obj) } end\n\n  it.tint = tryGetTint(obj)\n  -- Only ever true; false is left nil so the field costs nothing for the\n  -- overwhelming majority of objects, which are visible.\n  it.invis = tryIsInvisible(obj) or nil\n  -- XML UI capture (Spectator 13 XML build only)\n  it.xmlRaw = tryGetXmlRaw(obj)\n  if it.xml or it.xmlRaw then it.uiAssets = tryGetUiAssets(obj) end\n  it.kind = tryGetKind(obj)\n  -- VISUAL BOUNDS for a NATIVE object (Spectator Tool Autodraw build only,\n  -- 2026-09-14). `bounds` has always been objBoundsRound's getBoundsNormalized()\n  -- -- the merged COLLIDERS -- and for a built-in object that is often not the\n  -- shape anybody sees: Tileset_Tree's collider is the 1x1 box around its trunk\n  -- while its renderers are the whole canopy, so the site drew the tree at a\n  -- fraction of its real footprint. getVisualBoundsNormalized() is the merged\n  -- RENDERERS, the same \"as if the object were rotated to 0,0,0\" box, so it\n  -- drops straight into the same field and no new field rides the wire.\n  --\n  -- NATIVE ONLY, which is exactly what `kind` being a string means (tryGetKind\n  -- answers nil for Custom_*). A custom object keeps its collider bounds because\n  -- its images were calibrated against them, and moving the field would move\n  -- every custom drawing on the board at once. 3DText is out too: it is spliced\n  -- into its zone by GUID, has no collider for the old call either, and the site\n  -- sizes it from its font rather than from this.\n  --\n  -- COST: one engine read per FRAGMENT BUILD, on the path that has just paid for\n  -- getBoundsNormalized and a dozen other reads. Nothing is added to the\n  -- per-object per-tick scan or to lightObjSig. pcall'd, and any failure leaves\n  -- today's collider answer exactly where objBoundsRound put it.\n  if type(it.kind) == \"string\" and it.tag ~= \"3DText\" then\n    local okVB, vb = pcall(function() return obj.getVisualBoundsNormalized() end)\n    if okVB and vb and vb.size then it.bounds = vec3Round(vb.size, POS_DECIMALS) end\n  end\n  if it.tint == nil and it.kind ~= nil and it.tag ~= \"Card\" and it.tag ~= \"Deck\"\n     and not (it.face or it.front or it.back or it.img or it.img_front or it.img_back) then\n    it.tint = tryGetWideTint(obj)\n  end\n  it.meshIdx = tryGetMeshIndex(obj, it.tag)\n  if it.kind == \"3DText\" then it.text = tryGetText(obj) end\n\n  local btns = extractButtonsCached(obj, staleOk)\n  if btns then it.buttons = btns end\n\n  -- Inputs and decals (Spectator Tool Autodraw build only); see itemForObject,\n  -- including why an empty GUID is skipped rather than looked up.\n  local ioe = (it.guid ~= \"\") and BTN_CACHE[it.guid] or nil\n  if ioe then\n    it.inputs = ioe.inputs\n    it.decals = ioe.decals\n    -- The notecard's body (Spectator Tool Autodraw build only, 2026-09-14):\n    -- another pure cache read off the entry already in hand. Empty string for a\n    -- notecard with no description, nil -- and so absent from the wire -- for\n    -- every object that is not one.\n    it.desc = ioe.desc\n  end\n\n  local mu = tryGetMeshUrl(obj)\n  if mu then it.mesh = mu end\n\n  if tag == \"Card\" then\n    it.faceDown = isFaceDown(obj)\n    local hidden = it.faceDown and (not REVEAL_HIDDEN)\n    local front, back, w, h, idx, ub = tryCustomDeckImagesForCard(obj)\n    if front or back then\n      it.back = (back and back ~= \"\") and back or nil\n      it.w = w; it.h = h; it.i = idx\n      if ub and it.back then it.backIsSheet = true end\n      if hidden then\n        it.isBack = true\n      else\n        it.front = (front and front ~= \"\") and front or nil\n        if it.faceDown then\n          it.face = it.back or it.front\n          it.isBack = (it.face == it.back)\n        else\n          it.face = it.front or it.back\n          it.isBack = (it.face == it.back)\n        end\n      end\n    elseif not hidden then\n      local img = trySingleImage(obj)\n      if img then it.img = img end\n    end\n    if hidden then\n      -- keep guid, tag, transform, back; drop everything face-revealing\n      it.name = nil\n      it.front = nil\n      it.face = nil\n      it.img = nil\n    end\n    return it\n  end\n\n  do\n    local f, b = tryFrontBackImages(obj)\n    if f then it.img_front = f end\n    if b then it.img_back  = b end\n    if (not it.img_front) and (not it.img_back) then\n      local img = trySingleImage(obj)\n      if img then it.img = img end\n    end\n  end\n\n  return it\nend\n\n-- ONE WHOLE-OBJECT READ PER HAND ITEM (optimization candidate p2,\n-- 2026-09-21). The zone path opens AUTO.dataMemo around itemForObject (see\n-- encodedZoneItemFor) so a card's two tryGetData readers -- getCardIdFromData\n-- and then the CustomDeck read inside tryCustomDeckImagesForCard -- cost one\n-- obj.getData(). The hand path had no such scope, so a hand card whose\n-- speculative card-id getters fail (every card the 2026-09-20 probe saw)\n-- paid that read twice inside ONE capture. This is the hand path's scope:\n-- the same GUID-keyed memo, open for exactly one synchronous capture and\n-- released on every way out. The memo that was open on entry -- nil in\n-- every current caller -- is put BACK rather than nil'd, so a capture\n-- nested inside another object's build could never close its caller's\n-- scope. The memo is keyed on this object's GUID exactly as the zone\n-- wrapper keys it, so a contained object read inside the capture still\n-- goes to the engine. itemForHandObj is called with the same arguments\n-- and its table is handed back untouched: nothing on the wire changes.\nAUTO.handItem = function(obj, staleOk)\n  if not obj then return itemForHandObj(obj, staleOk) end\n  local guid = safeStr(obj.getGUID())\n  local prior = AUTO.dataMemo\n  AUTO.dataMemo = { guid = guid, data = nil, read = false }\n  local okItem, it = pcall(itemForHandObj, obj, staleOk)\n  AUTO.dataMemo = prior\n  if not okItem then error(it, 0) end\n  return it\nend\n\nlocal function snapshotHands()\n  local players = {}\n  local plist = AUTO.seatedPlayers()\n  if DEBUG_ENABLED then PROF.handPlayers = PROF.handPlayers + (#plist or 0) end\n\n  for _, p in ipairs(plist) do\n    local items = {}\n    forEachHandZone(p, function(handObjs, handIdx)\n      if DEBUG_ENABLED then PROF.handObjs = PROF.handObjs + (#handObjs or 0) end\n      for idx, obj in ipairs(handObjs) do\n        local it = AUTO.handItem(obj, true)  -- full path: stale-OK buttons\n        it.pos = idx                       -- 1-based index WITHIN this hand zone\n        if handIdx > 1 then it.handIdx = handIdx end  -- omit for zone 1 (site treats missing as 1)\n        table.insert(items, it)\n      end\n    end)\n    table.insert(players, { color = p.color, steamName = p.steam_name, hand = items })\n  end\n  return players\nend\n\n-- =========================\n-- CHEAP SIGNATURE (FAST CHANGE DETECTOR)\n-- =========================\nlocal function cheapHandSignatureForPlayer(p)\n  local parts = {}\n  forEachHandZone(p, function(handObjs, handIdx)\n    for idx, obj in ipairs(handObjs) do\n      local guid = obj.getGUID()\n      local pos = obj.getPosition()\n      -- encode the zone (handIdx.idx) so hand-2 contents and cards moving\n      -- between zones both change the signature and trigger a fresh snapshot\n      local s = guid .. \"@\" .. handIdx .. \".\" .. idx .. \":\" .. q(pos.x) .. \",\" .. q(pos.y) .. \",\" .. q(pos.z)\n      -- fold cached button labels into the gate too (pure lookup; see zones)\n      local bs = buttonsSig(guid)\n      if bs ~= \"\" then s = s .. \":\" .. bs end\n      -- RR covers hand objects too, so a card rename/scale in hand surfaces here.\n      local rms = rrMetaSig(guid)\n      if rms ~= \"\" then s = s .. \":\" .. rms end\n      parts[#parts + 1] = s\n    end\n  end)\n  return table.concat(parts, \"|\")\nend\n-- =========================\n-- THE SPLIT SCAN  (Spectator Tool Autodraw build only, 2026-09-12)\n-- =========================\n-- This build used to read the WHOLE table on every FULL tick -- one getGUID, one\n-- getPosition and one getRotation per object, about 18 ms of a 23-39 ms tick on\n-- a 206-object table -- and the user felt each of those as a dropped frame\n-- while dragging. This splits it across the two ticks the parity already\n-- alternates:\n--\n--   TICK A (odd)   AUTO.spreadA -- the IDENTITY pass. Every zone is read once,\n--                  every object's GUID once, AUTO.excl applied, and\n--                  AUTO.scanG / scanO / scanN filled COMPLETELY: one consistent\n--                  snapshot of what is on the table. Then the FIRST half of each\n--                  zone's list (indices 1 .. ceil(n/2)) is read for real.\n--   TICK B (even)  AUTO.spreadB -- the SECOND half, off the references tick A\n--                  captured, followed by AUTO.spreadSignature, which assembles\n--                  the whole-table signature out of every gate string. The\n--                  publish decision, and so the ordinary diff, happens there.\n--\n-- DEAD REFERENCES are the one hazard this introduces. A reference captured on\n-- tick A can be gone by tick B -- destroyed, or put into a container -- and\n-- reading one raises a .NET error that no pcall can see. The arrays are stamped\n-- with AUTO.scanAt at the top of the identity pass, and AUTO.deadRef is asked --\n-- against that stamp -- before ANY method call on one of them, here and in the\n-- ordinary diff;\n-- a skipped object keeps the gate string it already had, and the next identity\n-- pass drops it from the lists. See THE ONE DEAD RECORD for the rule.\n--\n-- NOTHING here takes a top-level local: every function hangs off AUTO, as\n-- everything in this build does.\n\n-- ONE object's gate string, and the ONLY copy of it in this build. This is the\n-- per-object body the whole-table scan carried until the scan was split: the pose\n-- first -- position and all three rotation axes -- handed to the hot set through\n-- AUTO.noteSeen exactly where it was, then the folds that make a stationary\n-- object's label, name, scale, XML, input, decal, counter, quantity or shuffle\n-- change move the signature. The finished string lands in AUTO.gateSig, which is\n-- what the ordinary diff's re-read gate compares against.\nAUTO.spreadGate = function(zg, g, o)\n  local p = o.getPosition()\n  local r = o.getRotation()\n  local s = g .. \"@\" .. q(p.x) .. \",\" .. q(p.y) .. \",\" .. q(p.z) .. \":\" .. q(r.x) .. \",\" .. q(r.y) .. \",\" .. q(r.z)\n  -- HOT SET: the pose alone, before the folds below, so a renamed button does\n  -- not make a stationary object look like it is moving.\n  AUTO.noteSeen(zg, g, o, s)\n  local bs = buttonsSig(g)\n  if bs ~= \"\" then s = s .. \":\" .. bs end\n  local rms = rrMetaSig(g)\n  if rms ~= \"\" then s = s .. \":\" .. rms end\n  if tostring(o.tag or \"\") == \"Counter\" then\n    local v = tryGetCounterValue(o)\n    if v ~= nil then s = s .. \"C\" .. tostring(v) end\n  end\n  if isContainerTag(tostring(o.tag or \"\")) then\n    s = s .. \"Q\" .. tostring(tryGetQuantity(o))\n  end\n  if SHUFFLE_EPOCH[g] then s = s .. \"E\" .. SHUFFLE_EPOCH[g] end\n  AUTO.gateSig[g] = s\nend\n\n-- TICK A. The identity pass plus the first half.\n--\n-- The identity pass is the part that must stay whole: it is what decides which\n-- objects exist, and the ordinary diff walks its arrays on tick B. Split it and\n-- the diff would see half of one snapshot and half of another, and emit a remove\n-- for an object that had merely not been looked at yet.\n--\n-- A zone the cache no longer hands over has its lists dropped, exactly as the\n-- default build's scan dropped its contribution by not walking it: without that\n-- a deleted zone's part would stay in the signature for ever and the site would\n-- never be told the zone is gone.\nAUTO.spreadA = function()\n  -- THE ARRAYS' CAPTURE STAMP, and the first thing this pass does: every\n  -- reference it is about to record comes out of a zone read that happens below\n  -- this line, so the count taken here is at or below the count each of them was\n  -- really captured at. One stamp for the whole pass is exact for the same reason\n  -- the warm-up's is: it is one synchronous walk, and nothing can die inside it.\n  AUTO.scanAt = AUTO.deathSeq\n  local zones = refreshZoneCacheIfNeeded(false)\n  local seen = {}\n  for _, z in ipairs(zones) do\n    if zoneAlive(z) then\n      local zg = safeStr(z.getGUID())\n      seen[zg] = true\n      local ok, objs = pcall(function() return zoneObjectsPlusTexts(z) end)\n      if not ok or type(objs) ~= \"table\" then\n        -- The read THREW: this tick knows nothing about the zone, so drop the\n        -- count. The ordinary diff falls back to reading the zone itself, where\n        -- the same throw is caught, exactly as the whole-table scan did.\n        AUTO.scanN[zg] = nil\n        AUTO.scanGen[zg] = (AUTO.scanGen[zg] or 0) + 1\n      else\n        local sg = AUTO.scanG[zg]\n        if sg == nil then sg = {}; AUTO.scanG[zg] = sg end\n        local so = AUTO.scanO[zg]\n        if so == nil then so = {}; AUTO.scanO[zg] = so end\n        local sPrevN = AUTO.scanN[zg] or 0\n        local sn = 0\n        -- SAME MEMBERS AS LAST TIME? (optimization candidate p3, 2026-09-21.)\n        -- Answered while the list is written: one compare per object against\n        -- the slot's previous occupant, before that slot is overwritten. The\n        -- same GUID in every slot and the same count is the same membership,\n        -- and the sorted order AUTO.spreadZonesSig keeps for this zone stays\n        -- valid. Anything else moves AUTO.scanGen, which is what makes that\n        -- order be rebuilt. A merely REORDERED list answers \"no\" as well and\n        -- costs one sort it did not need, which is safe; nothing can answer\n        -- \"yes\" for a list whose members changed. The slots past the previous\n        -- count are nil (the tail is nil'd whenever the count shrinks), so a\n        -- grown list answers \"no\" on its first new slot.\n        local same = true\n        for _, o in ipairs(objs) do\n          local g = o.getGUID()\n          -- Excluded objects -- hand cards, and the tool itself -- are left out\n          -- of the list entirely, so nothing downstream can publish one.\n          if not AUTO.excl[g] then\n            if sg[sn + 1] ~= g then same = false end\n            sn = sn + 1; sg[sn] = g; so[sn] = o\n          end\n        end\n        AUTO.scanN[zg] = sn\n        if not (same and sPrevN == sn) then\n          AUTO.scanGen[zg] = (AUTO.scanGen[zg] or 0) + 1\n        end\n        -- The tail is nil'd only when the count SHRANK: otherwise a stale slot\n        -- would hold a reference to an object that has left the zone alive.\n        if sPrevN > sn then\n          for i = sn + 1, sPrevN do sg[i] = nil; so[i] = nil end\n        end\n        -- ... and the FIRST half, read for real. No dead test here: every\n        -- reference in this list was handed over by the zone a few lines ago,\n        -- which is exactly the trust the whole-table scan placed in it, and the\n        -- stamp above says so. The test belongs where a reference is OLD -- tick\n        -- B and the ordinary diff.\n        local half = math.ceil(sn / 2)\n        for i = 1, half do\n          AUTO.spreadGate(zg, sg[i], so[i])\n        end\n      end\n    end\n  end\n  -- A zone that is no longer in the cache: drop its lists. Setting the key the\n  -- pairs() loop is CURRENTLY on to nil is explicitly allowed; nothing else here\n  -- touches another key of that table.\n  for zg, _ in pairs(AUTO.scanN) do\n    if not seen[zg] then\n      AUTO.scanN[zg] = nil\n      AUTO.scanG[zg] = nil\n      AUTO.scanO[zg] = nil\n      AUTO.scanGen[zg] = nil\n      AUTO.zoneOrder[zg] = nil\n    end\n  end\nend\n\n-- TICK B. The second half, off the references tick A captured -- no zone read,\n-- no getGUID, and not one method call on an object that has died since the\n-- identity pass stamped these arrays.\nAUTO.spreadB = function()\n  for zg, sn in pairs(AUTO.scanN) do\n    local sg, so = AUTO.scanG[zg], AUTO.scanO[zg]\n    if sg ~= nil and so ~= nil then\n      for i = math.ceil(sn / 2) + 1, sn do\n        local g, o = sg[i], so[i]\n        if g ~= nil and o ~= nil and not AUTO.deadRef(g, AUTO.scanAt) then\n          AUTO.spreadGate(zg, g, o)\n        end\n      end\n    end\n  end\nend\n\n-- The zones half of the cheap signature, assembled out of the gate strings the\n-- two halves recorded, in scan-array order and in the layout the whole-table\n-- scan (deleted 2026-09-27) used to build -- \"Z:<zone guid>:<count>:<sorted\n-- strings joined by ;>\", the parts themselves sorted and joined by \"||\" -- so\n-- every publish decision, every gate comparison and the site itself are\n-- untouched by any of this.\n--\n-- One difference from the whole-table scan, and it is deliberate: a zone whose\n-- read THREW contributes nothing here rather than \"Z:<guid>:ERR\". Both move the\n-- signature, which is all either answer is used for.\n-- THE CANONICAL ORDER OF A ZONE (optimization candidate p3, 2026-09-21).\n-- Every gate string begins with its object's GUID and \"@\", and a GUID holds\n-- no \"@\" -- so for a zone whose GUIDs are all distinct, sorting the gate\n-- strings puts them in exactly the order that sorting the keys `guid..\"@\"`\n-- puts the GUIDs: two strings with different GUIDs are decided inside the\n-- key, before either \"@\" is reached, and never by what follows. That order\n-- changes only when the MEMBERSHIP changes, which is what the identity pass\n-- reports through AUTO.scanGen. So it is computed here once per membership\n-- and kept in AUTO.zoneOrder, and AUTO.spreadZonesSig reads the fresh gate\n-- strings in that order with no sort at all. `sorted` is false -- and the\n-- caller sorts the strings the old way -- when the shortcut is not proven:\n-- a repeated GUID, a GUID that is not a string, or one containing \"@\".\nAUTO.zoneOrderBuild = function(sg, sn, gen)\n  local keys, seen, sorted = {}, {}, true\n  for i = 1, sn do\n    local g = sg[i]\n    if type(g) ~= \"string\" or seen[g] or string.find(g, \"@\", 1, true) then\n      sorted = false\n    else\n      seen[g] = true\n    end\n    keys[i] = (type(g) == \"string\") and (g .. \"@\") or \"\"\n  end\n  if sorted then\n    table.sort(keys)\n    for i = 1, sn do keys[i] = string.sub(keys[i], 1, -2) end\n  end\n  return { keys = keys, n = sn, sorted = sorted, gen = gen }\nend\n\nAUTO.spreadZonesSig = function()\n  local zparts = {}\n  for zg, sn in pairs(AUTO.scanN) do\n    local sg = AUTO.scanG[zg]\n    local oparts = {}\n    if sg ~= nil then\n      -- The order is REUSED while the identity pass has reported no\n      -- membership change since it was built (same generation, same count),\n      -- and rebuilt -- one sort -- otherwise. The gate strings themselves are\n      -- read fresh either way, so a pose change moves the signature exactly\n      -- as it always did, and a missing string is skipped exactly as it was.\n      local gen = AUTO.scanGen[zg]\n      local ord = AUTO.zoneOrder[zg]\n      if ord == nil or ord.gen ~= gen or ord.n ~= sn then\n        ord = AUTO.zoneOrderBuild(sg, sn, gen)\n        AUTO.zoneOrder[zg] = ord\n      end\n      -- One loop either way: over the remembered order when the shortcut is\n      -- proven, over the scan array -- followed by the sort -- when it is not.\n      local keys = ord.sorted and ord.keys or sg\n      for i = 1, sn do\n        local s = AUTO.gateSig[keys[i]]\n        if s ~= nil then oparts[#oparts + 1] = s end\n      end\n      if not ord.sorted then table.sort(oparts) end\n    end\n    zparts[#zparts + 1] = \"Z:\" .. zg .. \":\" .. tostring(#oparts) .. \":\" .. table.concat(oparts, \";\")\n  end\n  table.sort(zparts)\n  return table.concat(zparts, \"||\")\nend\n\n-- ... and the whole thing, hands included, exactly as the whole-table scan built\n-- it (deleted 2026-09-27). The hands are cheap (a per-player lookup, no zone walk) and are read on\n-- tick B, which is the tick that publishes.\nAUTO.spreadSignature = function()\n  local parts = {}\n  for _, p in ipairs(AUTO.seatedPlayers()) do\n    parts[#parts + 1] = \"H:\" .. p.color .. \":\" .. cheapHandSignatureForPlayer(p)\n  end\n  table.sort(parts)\n  parts[#parts + 1] = AUTO.spreadZonesSig()\n  -- HAND ZONES (2026-09-18). One string concat off a list the zone cache's\n  -- rescan built at most ten seconds ago: no engine call, nothing per object.\n  -- This is what makes a MOVED, ADDED or DELETED hand zone move the whole-table\n  -- signature, which is how tick B comes to publish one at all -- no other term\n  -- of this string can see a hand zone. Appended only when there is something to\n  -- say, so a table with no hand zones -- and this build with the switch off --\n  -- produces the byte-identical signature it always did.\n  local hz = AUTO.handZonesSig\n  if hz ~= nil and hz ~= \"\" then\n    parts[#parts + 1] = \"HZ:\" .. hz\n  end\n  -- THE TABLE'S INK (2026-09-18), on exactly the hand zones' rule above. One\n  -- string compare off a list the scanner read at most TICKS_MAX ticks ago: no\n  -- engine call, nothing per object. This is what makes a stroke publish AT\n  -- ALL -- no other term of this string can see the ink -- and an ERASE moves\n  -- the string the same way an addition does, by taking the term away.\n  -- Appended only when there is something to say, so a table nobody has drawn\n  -- on produces the byte-identical signature it always did.\n  local dr = AUTO.draw\n  if dr ~= nil and dr.sig ~= nil and dr.sig ~= \"\" then\n    parts[#parts + 1] = \"D:\" .. dr.sig\n  end\n  return table.concat(parts, \"\\n\")\nend\n\n-- =========================\n-- PAYLOAD (FULL + DIFF) (NEW)\n-- =========================\n-- Fragment-cached JSON assembly. JSON.encode of the ~219KB full payload cost\n-- ~861ms in TTS's pure-Lua encoder; most of the ~140 objects are unchanged\n-- between fulls, so we cache each object's encoded JSON keyed by its lightObjSig\n-- and re-encode only what changed. Assembly joins these pre-encoded chunks with\n-- literal structural punctuation ONLY: every leaf value still passes through\n-- JSON.encode (no hand-escaping, no string surgery on encoder output). Empty\n-- arrays are emitted as the literal \"[]\" (never JSON.encode an empty Lua table\n-- in an array position -- the encoder is ambiguous there).\n\n-- Time spent in JSON.encode + table.concat during the CURRENT build. Reset in\n-- buildPayload, read by publishIfNeeded so the profile still shows the encode\n-- share now that it is spread across fragments.\nlocal BUILD_JSON_SECS = 0\n\n-- Remaining TTL-refresh budget for the CURRENT full build (reset at the top of\n-- buildFullSnapshot). Caps how many TTL-expired-but-sig-identical fragments a\n-- single full re-encodes.\nlocal FRAG_REFRESH_LEFT = 0\n\n-- JSON.encode wrapper that accumulates its cost into t_json / BUILD_JSON_SECS\n-- (only when profiling; otherwise it is a bare JSON.encode).\nlocal function jencTimed(v)\n  -- THE WARM-UP'S JSON SHARE (Spectator Tool Autodraw build only, 2026-09-08),\n  -- print-only. While the warm-up is running -- and only then -- every encode is\n  -- timed into AUTO.setupJson, which AUTO.setupOne reads back as the `json` part\n  -- of that object's frag phase. One os.clock pair per encode for the few\n  -- seconds a warm-up lasts, and one boolean test the rest of the time.\n  --\n  -- THE ENCODER ITSELF IS THE BUNDLED ONE, on all three exits, and that is the\n  -- answer of a measurement rather than of an argument. The lean encoder was\n  -- wired in here on 2026-09-08 and taken back out on 2026-09-09, when the bench\n  -- ran on THREE objects in room 97V0 instead of one and the three lines stopped\n  -- agreeing with each other. Bundled against lean, three runs each:\n  --   Player Cards   41.2 / 38.8 / 38.2  vs  23.6 / 32.8 / 46.0\n  --   Tarot Deck     15.7 / 15.8 / 18.9  vs  21.0 / 19.9 / 10.5\n  --   Deck (396)     39.7 / 41.8 / 38.6  vs  22.2 / 26.5 / 51.2\n  -- Outputs equal every time. The lean one averages about 15% faster and swings\n  -- by 2x from run to run -- most likely the GC collecting the many small strings\n  -- it builds -- while the bundled one is steady. Fifteen per cent of the\n  -- encoding is not worth a second encoder on the path every payload and every\n  -- fragment takes, so this is where it stops. AUTO.jenc stays in the build for\n  -- the Debug-only bench and nothing else. NOTHING HERE MAY BE RE-WIRED WITHOUT A\n  -- FRESH BENCH LINE, in either direction: this repo cannot time MoonSharp.\n  if AUTO.setup.active then\n    local j0 = os.clock()\n    local s = JSON.encode(v)\n    local jdt = os.clock() - j0\n    AUTO.setupJson = (AUTO.setupJson or 0) + jdt * 1000\n    if DEBUG_ENABLED then\n      BUILD_JSON_SECS = BUILD_JSON_SECS + jdt\n      profAdd(\"t_json\", jdt)\n    end\n    return s\n  end\n  if not DEBUG_ENABLED then return JSON.encode(v) end\n  local t0 = os.clock()\n  local s = JSON.encode(v)\n  local dt = os.clock() - t0\n  BUILD_JSON_SECS = BUILD_JSON_SECS + dt\n  profAdd(\"t_json\", dt)\n  return s\nend\n\n-- table.concat wrapper, likewise timed into the encode share.\nlocal function jconcatTimed(arr, sep)\n  if not DEBUG_ENABLED then return table.concat(arr, sep) end\n  local t0 = os.clock()\n  local s = table.concat(arr, sep)\n  local dt = os.clock() - t0\n  BUILD_JSON_SECS = BUILD_JSON_SECS + dt\n  profAdd(\"t_json\", dt)\n  return s\nend\n\n-- Deterministic per-guid jitter (0.75x-1.25x of FRAG_TTL_SECONDS) so fragment\n-- expiries stagger across fulls instead of stampeding into one big re-encode.\nlocal function fragTtlFor(guid)\n  local h = 0\n  for i = 1, #guid do h = (h * 31 + string.byte(guid, i)) % 1000 end\n  return FRAG_TTL_SECONDS * (0.75 + (h / 1000) * 0.5)\nend\n\n-- Returns the encoded JSON string for one zone object, from FRAG_CACHE when the\n-- sig matches and the TTL is live, else re-encodes and caches.\nlocal function encodedZoneItemFor(obj, sig, staleOk)\n  local guid = safeStr(obj.getGUID())\n  local entry = FRAG_CACHE[guid]\n  if entry and entry.sig == sig then\n    if os.clock() < entry.expireAt then\n      if DEBUG_ENABLED then PROF.fragHits = PROF.fragHits + 1 end\n      return entry.json\n    end\n    -- Expired but content-identical by sig: refresh on a per-full BUDGET. TTL\n    -- expiry only self-heals rare drift that escapes lightObjSig (renames,\n    -- scale, shuffle previews, button colors), so deferring refreshes across\n    -- several fulls is safe -- sig-detected changes always re-encode\n    -- immediately (the branch below never budgets a sig mismatch).\n    if staleOk then\n      if FRAG_REFRESH_LEFT <= 0 then\n        -- Budget spent: serve the stale json WITHOUT touching expireAt, so it\n        -- remains a refresh candidate for the next full.\n        if DEBUG_ENABLED then PROF.fragStale = PROF.fragStale + 1 end\n        return entry.json\n      end\n      FRAG_REFRESH_LEFT = FRAG_REFRESH_LEFT - 1\n      -- fall through to the re-encode path\n    end\n    -- Diff-path (staleOk=false) TTL misses are sig-driven follow-ups and do\n    -- not consume the full budget; fall through to re-encode.\n  end\n\n  -- MISS. On the DIFF path (staleOk=false) a container whose sig changed (e.g.\n  -- a card drawn from a deck moved a Q) must re-read its peek NOW, before we\n  -- freeze its contents into the fragment under the new sig; a TTL-stale peek\n  -- would otherwise be served until the next content change.\n  if (not staleOk) and isContainerTag(obj.tag) then\n    local pe = PEEK_CACHE[guid]\n    -- ... but only when something OTHER THAN THE POSE changed (Spectator Tool\n    -- Autodraw build only, 2026-09-07). A bag that is merely being carried gets\n    -- a new signature on every hot tick, and re-reading its contents for that\n    -- costs a whole obj.getData(), 196 ms for this table's 2330-card bag.\n    -- Everything that CAN mean the contents moved is still in the part that is\n    -- compared: the quantity (Q), face-down, the shuffle epoch, the\n    -- tint, the buttons and the name folds. A first encode has no cached\n    -- fragment to compare against and invalidates as it always did.\n    if pe and ((entry == nil)\n               or AUTO.sigSansPose(sig) ~= AUTO.sigSansPose(entry.sig)) then\n      pe.nextAt = 0\n    end\n  end\n\n  -- ONE WHOLE-OBJECT READ PER FRAGMENT (Spectator Tool Autodraw build only,\n  -- 2026-09-07). tryGetData answers from this memo while it is open, so the\n  -- three obj.getData() calls a cold deck used to make inside this one build --\n  -- containerPeekRaw, then deckPreviewSprite for the DeckIDs and again for the\n  -- CustomDeck -- become one. It is keyed on the GUID, so a contained object or\n  -- the next object in the walk falls straight through to the engine.\n  --\n  -- IT CARRIES THREE THINGS NOW, all written on the fly by their own readers and\n  -- all released with the table: `data` / `read` (tryGetData, 2026-09-07),\n  -- `qty` / `qtyRead` (AUTO.containerQty, 2026-09-08) and `objs` / `objsRead`\n  -- (safeGetContainerObjects, 2026-09-08 -- the peek and deckPreviewSprite both\n  -- ask a deck for its contents inside one build, and that is now one\n  -- obj.getObjects()).\n  AUTO.dataMemo = { guid = guid, data = nil, read = false }\n  -- pcall for ONE reason, the same one AUTO.hotOnly has: the memo holds a whole\n  -- serialised object and must be gone on every path out of here, or the next\n  -- build of this same object would be served a stale one.\n  local okItem, item = pcall(itemForObject, obj, staleOk)\n  AUTO.dataMemo = nil\n  if not okItem then error(item, 0) end\n\n  -- Special-asset hook. Placed HERE, not inside itemForObject, because that function\n  -- returns from four different branches; one call site covers all of them.\n  do\n    local e, byUrl = specialFor(obj, safeStr(obj.tag), item.img_front or item.img)\n    if e then\n      item.sp = e.id                       -- WHICH special, so the site picks a renderer\n      if e.enrich then pcall(e.enrich, e, item, obj) end\n      if not byUrl then\n        -- Matched by the weak signal: the registered URL no longer appears on this\n        -- object, i.e. the mod re-uploaded its art. Rendering still works; say so\n        -- once per 10 min so tracked-assets.xlsx can be refreshed deliberately.\n        local now = os.clock()\n        if now >= (SPECIAL.fallbackLogAt or 0) then\n          SPECIAL.fallbackLogAt = now + 600\n          print(\"[Spectator] '\" .. tostring(e.id) .. \"' matched by memo, not URL -- its asset URL likely changed. See tracked-assets.xlsx.\")\n        end\n      end\n    end\n  end\n\n  local json = jencTimed(item)\n  FRAG_CACHE[guid] = { sig = sig, json = json, expireAt = os.clock() + fragTtlFor(guid) }\n  if DEBUG_ENABLED then PROF.fragMisses = PROF.fragMisses + 1 end\n  return json\nend\n\n-- The top-level `uiAssets` field: the GLOBAL custom-asset table, sent ONCE per\n-- payload instead of merged into every object's list (Spectator 13 XML build\n-- only). Returns the JSON fragment to splice into the payload -- `,\"uiAssets\":\n-- [...]` -- or \"\" for \"say nothing this time\".\n--\n-- WHEN IT IS SENT:\n--   * every FULL, whenever the table is non-empty. A full is a complete\n--     baseline, so it must stand on its own -- and it is also the repair path:\n--     a diff that never reaches the server takes its uiAssets with it, and the\n--     seq gap that leaves earns a 409 whose needFull forces exactly this.\n--   * a DIFF only when the table has CHANGED since it was last sent. On a still\n--     table that is never, so the steady-state diff pays one read and one\n--     string compare.\n-- An empty table says nothing at all, on either path: a mod that unregisters an\n-- asset leaves the site holding a mapping nothing references any more, which is\n-- harmless, and an empty array on the wire is not.\n--\n-- The signature is the entry count followed by every name and url, joined with\n-- newlines -- neither field can contain one. Deliberately NOT the encoded JSON:\n-- JSON.encode is the expensive call this whole change exists to avoid, and it is\n-- now paid only on the publishes that actually carry the table.\nUI_ASSETS.payloadField = function(isFull)\n  local list = globalUiAssets()\n  if #list == 0 then return \"\" end\n  local parts = { tostring(#list) }\n  for i = 1, #list do\n    parts[#parts + 1] = list[i].name\n    parts[#parts + 1] = list[i].url\n  end\n  local sig = table.concat(parts, \"\\n\")\n  if (not isFull) and sig == UI_ASSETS.sentSig then return \"\" end\n  UI_ASSETS.sentSig = sig\n  return ',\"uiAssets\":' .. jencTimed(list)\nend\n\n-- The top-level `handZones` field: the geometry AUTO.handZonesRead last read,\n-- in the shape UI_ASSETS.payloadField answers -- the JSON fragment to splice\n-- into the payload, or \"\" for \"say nothing this time\".\n--\n-- WHEN IT IS SENT:\n--   * every FULL that has a list. A full is a complete baseline and an ABSENT\n--     field means \"no hand zones\" there (stateFromFull, not applyDiff), which is\n--     exactly what the switch being OFF has to mean -- so OFF sends nothing at\n--     all and the forced full the button builds is what clears the boxes off the\n--     site.\n--   * a DIFF only when the geometry has CHANGED since the room was last told. On\n--     a settled table that is never: one string compare and nothing else.\n--\n-- AN EMPTY LIST IS A REAL ANSWER ON A DIFF, and the one asymmetry here: when the\n-- last hand zone is deleted the site has to be told, and on a diff `[]` is how\n-- (\"field absent\" means \"unchanged\" there). It can only happen when the room was\n-- told something else before -- the marker starts \"\" -- so a table with no hand\n-- zones never mentions the field at all. `[]` is written out rather than encoded\n-- because an empty Lua table has no unambiguous encoding.\n--\n-- IT STAMPS THE GEOMETRY AS SENT, exactly as the asset table does, and that is\n-- safe for exactly the same reason: buildDiffSnapshot counts a non-empty field\n-- in its verdict, so a payload carrying this is never a payload that gets\n-- skipped. A post that FAILS re-sends as a full (scheduleRetry forces one), and a\n-- full carries the list whatever the marker says.\nAUTO.handZonesField = function(isFull)\n  if not AUTO.HAND_ZONES then return \"\" end\n  local list = AUTO.handZones\n  local sig = AUTO.handZonesSig\n  if list == nil then\n    if isFull or sig == AUTO.handZonesSent then return \"\" end\n    AUTO.handZonesSent = sig\n    return ',\"handZones\":[]'\n  end\n  if (not isFull) and sig == AUTO.handZonesSent then return \"\" end\n  AUTO.handZonesSent = sig\n  return ',\"handZones\":' .. jencTimed(list)\nend\n\n-- =========================\n-- SPECTATOR VIEW  (Spectator Tool Autodraw build only, 2026-09-28)\n-- =========================\n-- WHAT THE USER ASKED FOR (2026-09-28): a switch named \"Spectator View\" with two\n-- states, \"Sees as Player\" (the default: the site shows what the HOST sees) and\n-- \"Sees as Grey Spectator\" (the site shows what a Grey spectator in TTS sees).\n--\n-- WHY: a TTS XML UI element can be shown to some players only -- its\n-- `visibility` attribute lists the colours, teams, Host or Admin that see it --\n-- and the site drew all of it (the omniscient view of 2026-08-28), so a\n-- playermat's options panel opened for Pink alone was drawn open on the site\n-- while the host saw it closed. The filtering happens in the Worker, where a\n-- viewer cannot reach what it strips; the tool only says WHO the site looks as,\n-- in the top-level `viewer` field (docs/protocol.md, \"Spectator View\"):\n--\n--   \"viewer\": { \"mode\": \"player\", \"host\": \"<the host's steam name>\" }\n--   \"viewer\": { \"mode\": \"grey\" }\n--\n-- The Worker finds the host's seat colour by that name in the `players` of the\n-- full. `mode: \"all\"` (no filtering) is accepted by the Worker and never sent\n-- by this build.\n--\n-- The state, all of it on AUTO (the top-level locals are pinned at 192):\n--   viewMode   -- \"player\" (the default) or \"grey\". NOT PERSISTED, exactly like\n--                 the Hot ticks and Hand zones switches: every load starts it at\n--                 \"player\".\n--   hostName   -- the host's steam name as last read: nil = not read since the\n--                 room was created, \"\" = read, and nobody is the host.\n--   viewerSent -- the signature (mode and host name) the ROOM was last told; \"\"\n--                 = told nothing. Cleared at room create, by the button and when\n--                 the host changes, so the next payload carries the field.\nAUTO.viewMode = \"player\"\nAUTO.hostName = nil\nAUTO.viewerSent = \"\"\n\n-- WHO IS THE HOST, read from the engine. Player.getPlayers() lists every player\n-- -- the host gone Grey included (Seat Probe, 2026-09-15) -- and the one whose\n-- `host` is true is the host. Three answers, and the callers need all three\n-- apart:\n--   a name -- the host's steam name;\n--   \"\"     -- the read worked and nobody is the host (or the host has no name):\n--             `host` is left off the wire and the Worker treats the view as Grey;\n--   nil    -- an engine call raised: nothing new is known, so the caller keeps\n--             what it had, and one failed read cannot flip the room to Grey and\n--             back.\n-- Every engine read is pcall'd. At most a dozen players, once per tick B and once\n-- per full.\nAUTO.hostRead = function()\n  local okP, all = pcall(function() return Player.getPlayers() end)\n  if not okP or type(all) ~= \"table\" then return nil end\n  for i = 1, #all do\n    local p = all[i]\n    local okH, isHost = pcall(function() return p.host end)\n    if okH and isHost == true then\n      local okN, name = pcall(function() return p.steam_name end)\n      if okN and type(name) == \"string\" then return name end\n      return \"\"\n    end\n  end\n  return \"\"\nend\n\n-- A NEW HOST IS A NEW ROOM UNDER THE SAME CODE (Spectator View, 2026-09-28, the\n-- user's call). Host migration hands the table to another player, and \"Sees as\n-- Player\" means \"sees as the host\", so what the site may show changes with it.\n-- pollLoop calls this once per tick B, just above the publish decision. When\n-- the host's name differs from the one on record, the room is re-baselined: the\n-- record takes the new name, the sent signature is cleared, and\n-- nextFullSnapshotAt = 0 -- the \"next publish is a full\" idiom toggleRevealHidden\n-- uses -- makes the decision a few lines below post a full on this same tick,\n-- carrying the new `viewer`.\n--\n-- NOT AUTO.forceEmit: that one sends ONE object again (it takes the object's\n-- GUID) and owes an ordinary diff, not a full.\n--\n-- A read that raised (nil) changes nothing. The record is nil only until the\n-- first full of a room has read the host, and that full goes out before any\n-- tick B can run this, so a new room does not pay for a second full; the chat\n-- line is kept for a real change of host.\nAUTO.viewerHostCheck = function()\n  local h = AUTO.hostRead()\n  if h == nil or h == AUTO.hostName then return end\n  local was = AUTO.hostName\n  AUTO.hostName = h\n  AUTO.viewerSent = \"\"\n  nextFullSnapshotAt = 0\n  if was ~= nil then\n    print(\"[Spectator] The host is now \" .. ((h ~= \"\") and h or \"nobody\")\n          .. \": the site gets a fresh full.\")\n  end\nend\n\n-- The top-level `viewer` field, in the shape AUTO.handZonesField answers: the\n-- JSON fragment to splice into the payload, or \"\" for \"say nothing this time\".\n--\n-- WHEN IT IS SENT (the wire contract, docs/protocol.md \"Spectator View\"):\n--   * on EVERY full. A full reads the host again, because a full is the room's\n--     new baseline and the Worker looks the host's colour up in the `players`\n--     that same full carries. The name read is recorded, so the tick's host\n--     check that follows finds nothing to do.\n--   * on a DIFF only when the signature -- mode and host name -- differs from\n--     the one the room was last told: after the button, or after a host change\n--     that has not gone out as a full yet. A diff does not read the host; it uses\n--     AUTO.hostName, which the tick's check keeps current. On a settled table\n--     that is one string compare.\n--\n-- `host` is left out when nobody could be named; grey mode never carries it (the\n-- Worker's grey view is Grey alone, whoever hosts).\n--\n-- IT STAMPS THE SIGNATURE AS SENT, as the hand-zone and asset fields do, and it\n-- is safe for the same reason: buildDiffSnapshot counts a non-empty field in its\n-- empty-diff verdict, so a payload carrying it is never a payload that gets\n-- skipped, and a post that FAILS re-sends as a full, which carries the field\n-- whatever the marker says.\nAUTO.viewerField = function(isFull)\n  if isFull or AUTO.hostName == nil then\n    local h = AUTO.hostRead()\n    if h ~= nil then AUTO.hostName = h end\n  end\n  local mode = (AUTO.viewMode == \"grey\") and \"grey\" or \"player\"\n  local host = AUTO.hostName or \"\"\n  local sig = mode .. \"|\" .. host\n  if (not isFull) and sig == AUTO.viewerSent then return \"\" end\n  AUTO.viewerSent = sig\n  if mode == \"player\" and host ~= \"\" then\n    return ',\"viewer\":{\"mode\":\"player\",\"host\":' .. jencTimed(host) .. '}'\n  end\n  return ',\"viewer\":{\"mode\":\"' .. mode .. '\"}'\nend\n\nlocal function buildFullSnapshot()\n  FRAG_REFRESH_LEFT = FRAG_REFRESH_BUDGET_PER_FULL  -- per-full TTL-refresh budget\n  local zones = refreshZoneCacheIfNeeded(false)\n  local zoneJsons = {}\n  -- A full is a COMPLETE baseline, so rebuild the diff baselines from scratch\n  -- alongside the JSON and swap them in wholesale at the end. Seeding them here\n  -- with the exact sigs this full encodes makes the next diff on a still table\n  -- empty instead of a ~all-objects re-send (empty LAST_ZONE_STATE was the bug).\n  local now = os.clock()\n  local newZoneState = {}\n\n  for _, z in ipairs(zones) do\n    -- Dead reference: skip. LAST_ZONE_STATE is replaced wholesale below, so a\n    -- skipped zone simply drops out of the baseline -- which matches the payload\n    -- we are emitting.\n    if zoneAlive(z) then\n      local zg = safeStr(z.getGUID())\n      local zname = safeStr(z.getName())\n      local label = zoneLabelFromName(zname)\n\n      local pos = vec3Round(z.getPosition(), POS_DECIMALS)\n      local rot = rotRound(z.getRotation(), ROT_DECIMALS)\n      local posTable = { x = pos.x, y = pos.y, z = pos.z }\n      local rotTable = { x = rot.x, y = rot.y, z = rot.z }\n\n      local scale = nil\n      do\n        local okS, sc = pcall(function() return z.getScale() end)\n        if okS and sc then\n          local sc2 = vec3Round(sc, POS_DECIMALS)\n          scale = { x = sc2.x, y = sc2.y, z = sc2.z }\n        end\n      end\n\n      -- Collect { guid, json } per object, then sort by guid to preserve\n      -- snapshotZones' deterministic (guid-sorted) object order.\n      local fragObjs = {}\n      local curObjs = {}  -- baseline objs table for this zone; mirrors buildDiffSnapshot's prev.objs\n      local ok, objs = pcall(function() return AUTO.filtered(z) end)\n      if ok and type(objs) == \"table\" then\n        if DEBUG_ENABLED then\n          PROF.zones = PROF.zones + 1\n          PROF.zoneObjs = PROF.zoneObjs + (#objs or 0)\n        end\n        for _, o in ipairs(objs) do\n          if o then\n            local og = safeStr(o.getGUID())\n            -- Order is load-bearing: seed name/scale AND populate the button cache\n            -- FIRST so lightObjSig folds both in, THEN encode the fragment under that\n            -- sig, THEN record the sig as the baseline. If any step ran out of order\n            -- the stored sig / fragment key would not match the seeded meta/buttons\n            -- and the very next diff would re-churn.\n            ensureMetaSeeded(o, og, now)\n            -- The full reads these buttons anyway inside encodedZoneItemFor ->\n            -- itemForObject; pulling the (idempotent, staleOk) read ahead of the\n            -- signature makes buttonsSig complete at sig time, so the recorded\n            -- baseline matches what the next diff computes and button-bearing still\n            -- objects (most of the table in e.g. Terraforming Mars) aren't re-sent.\n            -- staleOk=true: cold entries read once + set populated, warm entries hit;\n            -- do NOT use refreshButtonsEntry here (it force-re-reads every full).\n            extractButtonsCached(o, true)\n            local sig = lightObjSig(o, nil)\n            fragObjs[#fragObjs + 1] = { g = og, j = encodedZoneItemFor(o, sig, true) }\n            curObjs[og] = sig\n          end\n        end\n      else\n        if DEBUG_ENABLED then PROF.zones = PROF.zones + 1 end\n      end\n\n      -- Record this zone's baseline with the EXACT metaSig/objs shape\n      -- buildDiffSnapshot maintains, so it reads it back with no re-send.\n      newZoneState[zg] = { metaSig = zoneMetaSig(z), objs = curObjs }\n\n      table.sort(fragObjs, function(a, b) return a.g < b.g end)\n      local frags = {}\n      for i, fo in ipairs(fragObjs) do frags[i] = fo.j end\n\n      zoneJsons[#zoneJsons + 1] = '{\"guid\":' .. jencTimed(zg)\n        .. ',\"name\":' .. jencTimed(zname)\n        .. ',\"label\":' .. jencTimed(label)\n        .. ',\"pos\":' .. jencTimed(posTable)\n        .. ',\"rot\":' .. jencTimed(rotTable)\n        .. (scale and (',\"scale\":' .. jencTimed(scale)) or \"\")\n        .. ',\"objects\":[' .. jconcatTimed(frags, \",\") .. ']}'\n    end\n  end\n\n  -- Wholesale replace (not merge): a full is a complete baseline, so any zone\n  -- absent this build must drop. Same upvalue buildDiffSnapshot reads and the\n  -- reset at broadcast start reassigns.\n  LAST_ZONE_STATE = newZoneState\n\n  -- Hands are ~12 items; encoding them fresh is cheap. Do NOT fragment-cache\n  -- hand items -- their JSON differs between full and diff contexts.\n  local players = snapshotHands()\n  local playersJson = (#players > 0) and jencTimed(players) or \"[]\"\n\n  -- Seed the hand baseline the same way: a light second pass over the hands (the\n  -- first was snapshotHands for the payload) recording the exact { hand, order }\n  -- shape buildDiffSnapshot maintains, keyed by player color. Cheap -- hands are\n  -- ~12 items -- and it makes the next hand diff on a still table empty too.\n  local newHandState = {}\n  for _, p in ipairs(AUTO.seatedPlayers()) do\n    local cur, curOrder = {}, {}\n    forEachHandZone(p, function(handObjs, handIdx)\n      for idx, o in ipairs(handObjs) do\n        local og = safeStr(o.getGUID())\n        if og ~= \"\" then\n          ensureMetaSeeded(o, og, now)\n          cur[og] = lightObjSig(o, handIdx, idx)\n          curOrder[og] = idx\n        end\n      end\n    end)\n    -- ... and WHICH NAME went with it (Spectator Tool Autodraw build only,\n    -- 2026-09-16). The diff below sends a colour's steam name only when it\n    -- differs from the one on record, so a full has to leave that record\n    -- saying what the site has just been told.\n    newHandState[p.color] = { hand = cur, order = curOrder, name = p.steam_name }\n  end\n  LAST_HAND_STATE = newHandState\n\n  return '{\"type\":\"full\",\"code\":' .. jencTimed(roomCode)\n    .. ',\"ts\":' .. tostring(os.time())\n    .. ',\"seq\":' .. tostring(DIFF_SEQ)\n    .. UI_ASSETS.payloadField(true)\n    .. AUTO.handZonesField(true)\n    .. AUTO.drawingsField(true)\n    .. AUTO.viewerField(true)\n    .. ',\"players\":' .. playersJson\n    .. ',\"zones\":[' .. jconcatTimed(zoneJsons, \",\") .. ']}'\nend\n\nlocal function buildDiffSnapshot(forceFull)\n  local now = os.clock()\n  if forceFull or (nextFullSnapshotAt == 0) or (now >= nextFullSnapshotAt) then\n    nextFullSnapshotAt = now + FULL_SNAPSHOT_SECONDS\n    return buildFullSnapshot(), \"full\"\n  end\n\n  local zones = refreshZoneCacheIfNeeded(false)\n\n  local zoneJsons = {}  -- assembled zoneDiff JSON strings (adds/updates are\n                        -- pre-encoded per-object fragments, so we hand-assemble)\n  local seenZones = {}\n\n  local totalAdds, totalUpdates, totalRemoves = 0, 0, 0\n\n  -- THE ROTATING RE-READ (Spectator Tool Autodraw build only, 2026-09-08).\n  -- The gate below reuses an object's previous signature while the cheap\n  -- scan's string for it has not moved -- but the scan cannot see three of\n  -- the things lightObjSig digests: the counter value, the colour tint, and\n  -- movement finer than POS_EPS (a flip DOES move it: all three rotation\n  -- axes are in the scan string). So AUTO.LAP_PER_DIFF objects are\n  -- read for real on every ordinary diff whatever the gate says, and the\n  -- window moves on by that much, wrapping at the end of the walk: every\n  -- object is re-read at least once every ceil(n / LAP_PER_DIFF) diffs.\n  --\n  -- `walkN` counts objects as the zone loops visit them, ACROSS zones, so\n  -- the window is a window on the whole table and not one per zone. A HOT\n  -- diff takes no lap at all -- it walks the movers only, and reads every one\n  -- of them for real.\n  local walkN = 0\n  local lapFrom, lapTo = 0, 0\n  if not AUTO.hotOnly then\n    lapFrom = AUTO.lapCursor or 0\n    lapTo = lapFrom + AUTO.LAP_PER_DIFF\n  end\n\n  for _, z in ipairs(zones) do\n    -- Dead reference => empty guid => the existing guard below skips this zone,\n    -- so it never lands in seenZones and the zoneRemoved sweep at the end of this\n    -- build emits {\"zoneRemoved\":\"<guid>\"} for it. That is the correct outcome\n    -- for a deleted zone, and it self-heals if the reference was merely stale:\n    -- the rescan re-finds the zone and the next diff re-adds it from scratch.\n    local zg = zoneAlive(z) and safeStr(z.getGUID()) or \"\"\n    if zg ~= \"\" then\n      seenZones[zg] = true\n\n      local prev = LAST_ZONE_STATE[zg]\n      if not prev then\n        prev = { metaSig = \"\", objs = {} }\n        LAST_ZONE_STATE[zg] = prev\n      end\n\n      -- Scale, exactly as buildFullSnapshot/snapshotZones emit it. A zone the site\n      -- only ever learns about from a diff (i.e. one added mid-broadcast) has no other\n      -- source for its size: applyDiff creates the shell with scale null and only\n      -- overwrites meta fields the shell actually carries. Site-side that null falls\n      -- back to scale 1, which drew the zone as a 60px stub with every object placed\n      -- against the wrong transform.\n      local mscale = nil\n      do\n        local okS, sc = pcall(function() return z.getScale() end)\n        if okS and sc then\n          local sc2 = vec3Round(sc, POS_DECIMALS)\n          mscale = { x = sc2.x, y = sc2.y, z = sc2.z }\n        end\n      end\n\n      local meta = {\n        guid = zg,\n        name = safeStr(z.getName()),\n        label = zoneLabelFromName(safeStr(z.getName())),\n        pos = vec3Round(z.getPosition(), POS_DECIMALS),\n        rot = rotRound(z.getRotation(), ROT_DECIMALS),\n        scale = mscale,\n      }\n\n      local newMetaSig = zoneMetaSig(z)\n      local metaChanged = (newMetaSig ~= (prev.metaSig or \"\"))\n\n      local curObjs\n      -- HOT TICK (Spectator Tool Autodraw build only, 2026-09-07). AUTO.filtered\n      -- hands this walk the zone's MOVING objects only, so almost nothing in the\n      -- zone is visited. PATCHED IN PLACE (optimization candidate p4,\n      -- 2026-09-21): the baseline map itself is the map this walk writes to.\n      -- It used to be copied entry by entry into a fresh map, and the copy\n      -- swept again below for removals a hot walk can never find -- it visits\n      -- the movers, so every other entry was always carried forward untouched.\n      -- Writing the movers straight into prev.objs leaves the map in exactly\n      -- the state the copy was left in: an add lands as a new key, an update\n      -- overwrites its key, an unchanged mover overwrites its key with the same\n      -- string, and the two caps write nothing, or the old string, as before.\n      -- Each mover is read (oldSig) before it is written and the hot list\n      -- names an object once, so no read sees this walk's own write. A build\n      -- that raises half way leaves this zone's map partly patched -- which is\n      -- the state a zone EARLIER in this same loop was already left in by the\n      -- copy design -- and publishIfNeeded answers every raise the same way:\n      -- the seq is refunded and the next payload is forced FULL, which rebuilds\n      -- every baseline wholesale. The ordinary walk keeps its fresh map and\n      -- its remove sweep untouched.\n      if AUTO.hotOnly then\n        curObjs = prev.objs\n      else\n        curObjs = {}\n      end\n      local adds, updates, removes = {}, {}, {}\n\n      -- THE ORDINARY DIFF WALKS THE SCAN'S LISTS (Spectator Tool Autodraw\n      -- build only, 2026-09-08). The cheap signature scan walked this same\n      -- zone a few lines ago, in this same tick, and already holds the GUID\n      -- and the reference of every object it did not exclude. Reading them\n      -- back costs two array indexes an object; the old path cost a second\n      -- z.getObjects(), a pcall'd getGUID per object inside AUTO.filtered\n      -- and a third getGUID per object here -- about 2 x 203 engine calls\n      -- and 203 pcall closures on this table, 8-10 ms of every diff.\n      --\n      -- THE FALLBACK is the whole safety net, and it is one nil test: a\n      -- zone the scan has no count for -- the first tick of a room, a zone\n      -- that has just appeared, a zone whose read threw -- is read for real,\n      -- and so is every zone of a HOT diff, where AUTO.filtered answers with\n      -- the movers and the scan's whole-table lists would be the wrong\n      -- question. The fallback builds the same two arrays, so there is ONE\n      -- loop body below either way.\n      local wg, wo, wn = AUTO.scanG[zg], AUTO.scanO[zg], nil\n      if not AUTO.hotOnly then wn = AUTO.scanN[zg] end\n      if wn == nil then\n        wg, wo, wn = {}, {}, 0\n        local ok, objs = pcall(function() return AUTO.filtered(z) end)\n        if ok and type(objs) == \"table\" then\n          for _, o in ipairs(objs) do\n            wn = wn + 1; wg[wn] = safeStr(o.getGUID()); wo[wn] = o\n          end\n        end\n      end\n      if wn > 0 then\n        for wi = 1, wn do\n          local og, o = wg[wi], wo[wi]\n          if og ~= \"\" then\n            local oldSig = prev.objs[og]\n            -- REUSE WHAT THE CHEAP SCAN ALREADY PAID FOR (Spectator Tool\n            -- Autodraw build only, 2026-09-08). lightObjSig is 300-500 us an\n            -- object -- getPosition, getRotation, isFaceDown, getValue,\n            -- getColorTint, getQuantity and a twelve-part concat -- and this\n            -- tick's cheap scan has already read every one of these objects.\n            -- When the scan's string for this object is the same one its\n            -- light signature was last computed under, nothing the scan can\n            -- see has changed and the previous signature is reused untouched.\n            local sig\n            -- DEAD BETWEEN TICK A AND TICK B (Spectator Tool Autodraw build\n            -- only, 2026-09-12). This reference came out of the identity pass's\n            -- list and may have been destroyed or put into a container since;\n            -- reading one raises a .NET error no pcall can see. So it is asked\n            -- against the stamp that list was captured at, ABOVE both reads --\n            -- the gate's miss and the rotating lap's forced re-read -- so neither\n            -- can touch it, and the object keeps whatever the baseline held.\n            -- ... and only on the ORDINARY walk: a hot diff's list comes from\n            -- AUTO.hotListFor, whose entries carry the stamp of the EVENT that\n            -- handed the reference over, so they have already been tested against\n            -- their own capture. Testing them against the SCAN's stamp as well\n            -- would freeze a token that went into a bag and came straight back out.\n            if (not AUTO.hotOnly) and AUTO.deadRef(og, AUTO.scanAt) then\n              -- Still counted in the walk, so the lap window goes on sweeping\n              -- the table at the same rate whatever has just died.\n              walkN = walkN + 1\n              sig = oldSig\n            elseif AUTO.hotOnly then\n              -- The hot walk sees only the movers, so a gate stamped from it\n              -- would claim something about a walk that never happened. Read\n              -- for real and CLEAR the stamp, so the next ordinary diff\n              -- re-reads this object under the scan string like the rest.\n              sig = lightObjSig(o, nil)\n              AUTO.diffGate[og] = nil\n            else\n              walkN = walkN + 1\n              local gs = AUTO.gateSig[og]\n              if oldSig ~= nil and gs ~= nil and gs == AUTO.diffGate[og]\n                 and not (walkN > lapFrom and walkN <= lapTo) then\n                sig = oldSig\n              else\n                sig = lightObjSig(o, nil)\n                AUTO.diffGate[og] = gs\n              end\n            end\n            if (not AUTO.hotOnly) and AUTO.deadRef(og, AUTO.scanAt) then\n              -- DEAD REFERENCE (Spectator Tool Autodraw build only). `sig` is\n              -- the baseline's own entry: nil for an object this diff has never\n              -- published, which is nothing to say about it, and otherwise\n              -- carried forward so the remove sweep below does not take a live\n              -- object off the board.\n              -- Either way no fragment is built, and nothing touches the\n              -- reference. The next identity pass drops it from the lists, and\n              -- the diff after that emits the remove.\n              if oldSig ~= nil then curObjs[og] = oldSig end\n            elseif not oldSig then\n              -- new object => add\n              if totalAdds < MAX_DIFF_OBJECT_ADDS then\n                -- FLIGHT (Spectator Tool Autodraw build only, 2026-09-08;\n                -- ordinary posts too since 2026-09-12): splices \"flight\"\n                -- into the fragment when this object's hot entry is holding\n                -- one, and hands back the cached string unchanged otherwise.\n                adds[#adds+1] =\n                  AUTO.flightSplice(og, encodedZoneItemFor(o, sig, false))\n                totalAdds = totalAdds + 1\n                curObjs[og] = sig\n              end\n              -- budget exhausted: do NOT record it, so it is still \"new\" next\n              -- tick and gets re-added (leave curObjs[og] unset).\n            elseif oldSig ~= sig then\n              if totalUpdates < MAX_DIFF_OBJECT_UPDATES then\n                updates[#updates+1] =\n                  AUTO.flightSplice(og, encodedZoneItemFor(o, sig, false))\n                totalUpdates = totalUpdates + 1\n                curObjs[og] = sig\n              else\n                -- budget exhausted: keep the OLD sig so this item is re-sent\n                -- next tick (do NOT record the new sig -> would lose the change).\n                curObjs[og] = oldSig\n              end\n            else\n              -- unchanged: carry the sig forward so it is not seen as removed\n              curObjs[og] = sig\n            end\n          end\n        end\n      end\n\n      -- REMOVALS ARE THE ORDINARY WALK'S (optimization candidate p4): a hot\n      -- walk never emits one -- with curObjs the baseline itself every key is\n      -- present by definition -- so the sweep is not made at all on a hot\n      -- publish, rather than made to find nothing.\n      if not AUTO.hotOnly then\n      for og, oldSig in pairs(prev.objs) do\n        if not curObjs[og] then\n          if totalRemoves < MAX_DIFF_OBJECT_REMOVES then\n            removes[#removes+1] = og\n            totalRemoves = totalRemoves + 1\n            FRAG_CACHE[og] = nil  -- object gone: drop its cached fragment\n            SHUFFLE_EPOCH[og] = nil  -- and its shuffle epoch\n          else\n            -- budget exhausted: carry the old entry forward so this object is\n            -- still present-in-prev / absent-in-cur next tick and the remove\n            -- is re-emitted (mirrors the add/update truncation fix).\n            curObjs[og] = oldSig\n          end\n        end\n      end\n      end\n\n      prev.metaSig = newMetaSig\n      prev.objs = curObjs\n\n      if metaChanged or (#adds > 0) or (#updates > 0) or (#removes > 0) then\n        local removesJson\n        if #removes > 0 then\n          local rparts = {}\n          for i, og in ipairs(removes) do rparts[i] = jencTimed(og) end\n          removesJson = \"[\" .. jconcatTimed(rparts, \",\") .. \"]\"\n        else\n          removesJson = \"[]\"\n        end\n        zoneJsons[#zoneJsons+1] = '{\"zone\":' .. jencTimed(meta)\n          .. ',\"metaChanged\":' .. (metaChanged and \"true\" or \"false\")\n          .. ',\"add\":[' .. jconcatTimed(adds, \",\") .. ']'\n          .. ',\"update\":[' .. jconcatTimed(updates, \",\") .. ']'\n          .. ',\"remove\":' .. removesJson .. '}'\n      end\n    end\n  end\n\n  -- THE LAP MOVES ON (Spectator Tool Autodraw build only, 2026-09-08), once\n  -- per ordinary diff and never on a hot one -- a hot walk visits the movers,\n  -- so advancing the cursor by it would skip most of the table.\n  if not AUTO.hotOnly then\n    local nxt = lapTo\n    if nxt >= walkN then nxt = 0 end\n    AUTO.lapCursor = nxt\n  end\n\n  for zg, _ in pairs(LAST_ZONE_STATE) do\n    if not seenZones[zg] then\n      zoneJsons[#zoneJsons+1] = '{\"zoneRemoved\":' .. jencTimed(zg) .. '}'\n      LAST_ZONE_STATE[zg] = nil\n    end\n  end\n\n  local handDiffs = {}\n  -- HOT POSTS SKIP THE HANDS (Spectator Tool Autodraw build only,\n  -- 2026-09-08). A hot payload carries the moving objects of the zones\n  -- and nothing else, so this walk -- every seated player, every hand\n  -- zone, a lightObjSig per card -- can only ever produce work it will\n  -- then throw away. With no players to walk, LAST_HAND_STATE is left\n  -- exactly as it stands and the next ORDINARY diff publishes any hand\n  -- change against it, at the cadence hands had before hot ticks existed.\n  local players = (not AUTO.hotOnly) and AUTO.seatedPlayers() or {}\n  local seenPlayers = {}\n\n  for _, p in ipairs(players) do\n    local color = p.color\n    seenPlayers[color] = true\n\n    -- A COLOUR NOBODY HAS HEARD OF YET (Spectator Tool Autodraw build only,\n    -- 2026-09-16). Remembered rather than re-derived below, because the\n    -- record is created here and would look old by the time the emission\n    -- reads it. The record keeps its base shape -- `name` stays nil -- so the\n    -- first emission for this colour stamps the name it actually sent.\n    local isNew = false\n    local prev = LAST_HAND_STATE[color]\n    if not prev then\n      prev = { hand = {}, order = {} }\n      LAST_HAND_STATE[color] = prev\n      isNew = true\n    end\n\n    local cur, curOrder = {}, {}\n    local adds, updates, removes = {}, {}, {}\n\n    forEachHandZone(p, function(handObjs, handIdx)\n      for idx, o in ipairs(handObjs) do\n        local og = safeStr(o.getGUID())\n        if og ~= \"\" then\n          local sig = lightObjSig(o, handIdx, idx)\n          cur[og] = sig\n          curOrder[og] = idx\n\n          local oldSig = prev.hand[og]\n          local oldIdx = prev.order[og]\n          if not oldSig then\n            local it = AUTO.handItem(o, false)  -- diff path: TTL-fresh buttons\n            it.pos = idx  -- 1-based index WITHIN this hand zone (matches full)\n            if handIdx > 1 then it.handIdx = handIdx end  -- omit for zone 1\n            adds[#adds+1] = it\n          elseif oldSig ~= sig or oldIdx ~= idx then\n            local it = AUTO.handItem(o, false)  -- diff path: TTL-fresh buttons\n            it.pos = idx  -- 1-based index WITHIN this hand zone (matches full)\n            if handIdx > 1 then it.handIdx = handIdx end  -- omit for zone 1\n            updates[#updates+1] = it\n          end\n        end\n      end\n    end)\n\n    for og, _ in pairs(prev.hand) do\n      if not cur[og] then removes[#removes+1] = og end\n    end\n\n    prev.hand = cur\n    prev.order = curOrder\n\n    -- THE NAME RIDES AN EMPTY ENTRY TOO (Spectator Tool Autodraw build only,\n    -- 2026-09-16). This entry is the ONE place a colour's steam name reaches\n    -- the site, and it used to be sent only when that hand had gained,\n    -- changed or lost a card. So somebody who sat down -- or changed seats --\n    -- with an EMPTY hand never sent a name at all, and the site labelled\n    -- their cursor with the colour instead of with who is sitting there.\n    -- A colour new to LAST_HAND_STATE, and a colour whose name has changed\n    -- since it was last sent, therefore emit as well: three empty arrays,\n    -- which is exactly what the site expects -- it rewrites steamName from\n    -- any entry and tolerates an empty add / update / remove.\n    -- The stamp is what keeps this to ONE post per change rather than one per\n    -- tick. p.steam_name may be nil, which is a valid value here: it is only\n    -- ever compared with ~= and never called on, and an entry that carries no\n    -- steamName leaves the name the site already has.\n    if isNew or (#adds > 0) or (#updates > 0) or (#removes > 0) or (p.steam_name ~= prev.name) then\n      prev.name = p.steam_name\n      handDiffs[#handDiffs+1] = {\n        player = { color = color, steamName = p.steam_name },\n        add = adds,\n        update = updates,\n        remove = removes,\n      }\n    end\n  end\n\n  for color, _ in pairs(LAST_HAND_STATE) do\n    -- ... and the seat sweep goes with it: seenPlayers is empty on a hot\n    -- post because nothing was walked, not because everybody left the\n    -- table (Spectator Tool Autodraw build only, 2026-09-08).\n    if (not AUTO.hotOnly) and not seenPlayers[color] then\n      handDiffs[#handDiffs+1] = { playerRemoved = color }\n      LAST_HAND_STATE[color] = nil\n    end\n  end\n\n  -- Hand diffs stay Lua tables (not fragment-cached); encode the whole array in\n  -- one call. Inside a non-empty handDiff the empty add/update/remove tables\n  -- encode exactly as they always have -- consumers already tolerate that.\n  local handJson = (#handDiffs > 0) and jencTimed(handDiffs) or \"[]\"\n\n  -- NOTHING TO SAY, SAY NOTHING (Spectator Tool Autodraw build only,\n  -- 2026-09-12). Three things a diff can carry, and this one carries\n  -- none of them: zoneJsons is empty (no zone had an add, an\n  -- update, a remove or a meta change, and no zone disappeared),\n  -- handDiffs is empty (no hand change and no seat left), and the global\n  -- asset field came back empty (unchanged since it was last sent).\n  --\n  -- The asset field has to be READ HERE, into a local, rather than left in\n  -- the return below: UI_ASSETS.payloadField stamps the table as sent, and\n  -- a payload we then declined to post would have taken the asset table\n  -- with it and never sent it.\n  --\n  -- A HOT payload is judged by the same three (2026-09-13). It walks\n  -- the movers alone and skips the hand walk, so handDiffs is always\n  -- empty there and the finding is `zoneJsons` empty -- \"nothing new\n  -- about the movers\". The asset term still matters: a hot payload\n  -- carrying only the global asset table is NOT empty. What differs is\n  -- what the SKIP then settles, not how the verdict is reached.\n  local uiField = UI_ASSETS.payloadField(false)\n  -- ... and the hand-zone geometry, on the same rule and for the same reason\n  -- (2026-09-18): AUTO.handZonesField stamps it as sent, so it is read into a\n  -- local here and counted in the verdict below.\n  local hzField = AUTO.handZonesField(false)\n  -- ... and the table's ink, on the same rule and for the same two reasons\n  -- (2026-09-18): AUTO.drawingsField stamps the ink as sent, so it is read\n  -- into a local here; and a diff whose only news is a stroke -- drawn or\n  -- ERASED -- is not an empty diff, so it is counted in the verdict below.\n  local drField = AUTO.drawingsField(false)\n  -- ... and who the site looks as (Spectator View, 2026-09-28), on the same rule\n  -- and for the same two reasons: AUTO.viewerField stamps its signature as sent,\n  -- so it is read into a local here; and a diff whose only news is the button\n  -- or the host is not an empty diff.\n  local vwField = AUTO.viewerField(false)\n  AUTO.diffEmpty = #zoneJsons == 0 and #handDiffs == 0 and uiField == \"\"\n                   and hzField == \"\"\n                   and drField == \"\"\n                   and vwField == \"\"\n\n  return '{\"type\":\"diff\",\"code\":' .. jencTimed(roomCode)\n    .. ',\"ts\":' .. tostring(os.time())\n    .. ',\"seq\":' .. tostring(DIFF_SEQ)\n    .. uiField\n    .. hzField\n    .. drField\n    .. vwField\n    .. ',\"zoneDiffs\":[' .. jconcatTimed(zoneJsons, \",\") .. ']'\n    .. ',\"handDiffs\":' .. handJson .. '}', \"diff\"\nend\n\nlocal function buildPayload(forceFull)\n  DIFF_SEQ = (DIFF_SEQ or 0) + 1\n  -- EMPTY ORDINARY DIFFS (Spectator Tool Autodraw build only,\n  -- 2026-09-12). Cleared here, at the ONE door every payload goes\n  -- through, so publishIfNeeded can never read last build's answer --\n  -- including after a build that raised.\n  AUTO.diffEmpty = false\n  BUILD_JSON_SECS = 0  -- reset the per-build encode-share accumulator\n  if not DIFF_ENABLED then\n    -- Legacy (non-diff) full: still one whole-table encode (rarely used).\n    return jencTimed({ code = roomCode, ts = os.time(), players = snapshotHands(), zones = snapshotZones() }), \"legacy\"\n  end\n  return buildDiffSnapshot(forceFull)\nend\n\n-- =========================\n-- NETWORK\n-- =========================\nlocal function doCreateRoom(cb)\n  local body = \"{}\"\n  local headers = { [\"Content-Type\"] = \"application/json\" }\n\n  WebRequest.custom(CREATE_URL, \"POST\", true, body, headers, function(req)\n    print(\"CREATE status: \" .. tostring(req.response_code))\n\n    if req.is_error then\n      print(\"CREATE error: \" .. tostring(req.error))\n      print(\"CREATE response: \" .. tostring(req.text))\n      cb(false); return\n    end\n    if req.response_code ~= 200 then\n      print(\"CREATE response: \" .. tostring(req.text))\n      cb(false); return\n    end\n\n    local ok, data = pcall(function() return JSON.decode(req.text) end)\n    if not ok or not data or not data.ok then\n      print(\"CREATE parse failed: \" .. tostring(req.text))\n      cb(false); return\n    end\n\n    roomCode = data.code\n    writeToken = data.writeToken\n    -- v13: /create no longer returns gameKey; updates go to /update/<code>.\n\n    -- reset caches/timers\n    CACHED_ZONES = nil\n    nextZoneRescanAt = 0\n    BTN_CACHE = {}\n    -- ... and the presence state (2026-09-15), which describes the PREVIOUS\n    -- room's seats: a `gone` colour left standing would tell the new room's site\n    -- to remove a cursor it never had.\n    AUTO.presReset()\n    PEEK_CACHE = {}\n    FRAG_CACHE = {}\n    SHUFFLE_EPOCH = {}\n    -- The new room has never been told the global UI asset table, so the first\n    -- payload it gets must carry it (Spectator 13 XML build only).\n    UI_ASSETS.sentSig = nil\n    -- ... and the hand-zone geometry, which the new room has not been told\n    -- either (2026-09-18). \"\" and not nil: the marker says \"the room knows\n    -- nothing\", which is what an empty list says too, so a table with no hand\n    -- zones at all never puts one on the wire.\n    AUTO.handZonesSent = \"\"\n    -- ... and the table's ink, which the new room has not been told either\n    -- (2026-09-18). Only the two ROOM-shaped fields: the signature and the\n    -- fragment cache describe the table and are kept, so the first full of\n    -- the new room carries the ink that is already on the board.\n    AUTO.draw.sent = \"\"\n    AUTO.draw.nextTick = 0\n    -- ... and who the site looks as (Spectator View, 2026-09-28): the new room\n    -- has been told nothing, and its first full reads the host afresh. The\n    -- mode itself is the button's and is kept.\n    AUTO.viewerSent = \"\"\n    AUTO.hostName = nil\n\n    RR_OBJECTS = {}\n    RR_CONTAINERS = {}\n    rrObjIdx = 1\n    rrContIdx = 1\n    nextRRRebuildAt = 0\n    RR_META.visited = {}; RR_META.contVisited = {}\n    AUTO.tick = 0; AUTO.hot = {}; AUTO.hotSig = {}\n    AUTO.hotPending = false; AUTO.hotOnly = false\n    -- ... and THE BIG DECK ROSTER (2026-09-17), which describes the decks\n    -- of the PREVIOUS room. Every big deck of the new one is learned again\n    -- on its first fragment build, for one getData each.\n    AUTO.deckRoster = {}\n    -- ... and the ordinary diff's re-read gate (2026-09-08), which is a\n    -- cache of the previous room's objects like any other.\n    AUTO.gateSig = {}; AUTO.diffGate = {}; AUTO.lapCursor = 0\n    -- ... and the scan's per-zone lists, which hold a live reference to\n    -- every object of the PREVIOUS room's zones (2026-09-08).\n    AUTO.scanG = {}; AUTO.scanO = {}; AUTO.scanN = {}\n    AUTO.scanGen = {}; AUTO.zoneOrder = {}\n    -- ... and the 2026-09-08 state: the death debt of the PREVIOUS room, and\n    -- any flight callback it armed, which the new generation retires.\n    AUTO.fullPending = false\n    AUTO.flightPending = {}\n    AUTO.flightLoop.gen = (AUTO.flightLoop.gen or 0) + 1\n    AUTO.flightLoop.armed = false\n    -- ... and every death record of the PREVIOUS room (2026-09-13). The\n    -- counter itself is deliberately not reset: it is a clock, and a clock\n    -- that ran backwards would call a reference captured in the old room\n    -- live in the new one. Both scan stamps are moved up with it, because\n    -- the tables they belong to are emptied here too.\n    AUTO.deathAt = {}\n    AUTO.rrAt = AUTO.deathSeq; AUTO.scanAt = AUTO.deathSeq\n\n    -- reset diff state (NEW)\n    DIFF_SEQ = 0\n    nextFullSnapshotAt = 0\n    LAST_ZONE_STATE = {}\n    LAST_HAND_STATE = {}\n\n    if DEBUG_ENABLED then\n      profResetWindow()\n      PROF.nextLogAt = os.clock() + DEBUG_LOG_SECONDS\n    end\n\n    TEXT_GUIDS = {}; TEXT_LAST = {}\n    print(\"[Spectator] 3DText tracked: \" .. tostring(scanTexts()))\n    -- The code is announced by AUTO.setupFinish instead (Spectator Tool\n    -- Autodraw build only), when the warm-up is done and there is actually a\n    -- board for a spectator to look at. Until then the room merely exists.\n    print(\"[Spectator] Room \" .. tostring(roomCode) .. \" reserved, setting up...\")\n    cb(true)\n  end)\nend\n\n-- WHY THERE IS NO TERMINAL GIVE-UP HERE:\n-- the old code returned early once retryAttempts hit MAX_RETRY_ATTEMPTS and\n-- never rearmed, because retryAttempts was only ever reset by an HTTP 200 or by\n-- the Broadcast toggle. Three consecutive failures therefore stranded the tool\n-- FOREVER: it kept polling, kept printing healthy zone counts, and never posted\n-- again, so the site froze until a human toggled Broadcast off and on. A\n-- broadcaster that is still ON must never stop trying -- the retry budget may\n-- only decide HOW OFTEN we retry, never WHETHER we retry.\nlocal function scheduleRetry(isNetworkError)\n  if not broadcasting then return end\n  if retryPending then return end\n  -- Only unanswered posts consume the budget. A server-rejected-but-answered\n  -- post (409 needFull, 413, 500) is not a network failure and must not push us\n  -- toward the slow lane -- 409 needFull in particular is the protocol's\n  -- DESIGNED self-heal, not an error. (404/401 never reach here at all: they are\n  -- terminal and stop broadcasting -- see publishIfNeeded's callback.)\n  if isNetworkError then retryAttempts = retryAttempts + 1 end\n  local delay = (retryAttempts <= MAX_RETRY_ATTEMPTS) and RETRY_DELAY or RETRY_SLOW_DELAY\n  retryPending = true\n  if DEBUG_ENABLED then PROF.retries = PROF.retries + 1 end\n\n  Wait.time(function()\n    retryPending = false\n    if not broadcasting then return end\n    -- Do NOT clobber inFlight the way the old code did: a post started by\n    -- pollLoop while this timer was pending is still running, and forcing a\n    -- second concurrent POST could land out of order and manufacture the very\n    -- seq gap we are recovering from. Re-arm instead, so we still cannot fall\n    -- silent if that post never reports back.\n    if inFlight then scheduleRetry(false); return end\n    lastPostAt = 0\n    publishIfNeeded(true) -- force full/diff publish attempt\n  end, delay)\nend\n\nfunction publishIfNeeded(force)\n  if not broadcasting then return end\n  -- SETTING UP (Spectator Tool Autodraw build only): nothing may be published\n  -- until the warm-up has finished, force or no force. Otherwise the very cold\n  -- full this exists to avoid gets built here instead -- by the\n  -- FIRST_PUBLISH_DELAY timer the Broadcast toggle arms, by the retry timer, or\n  -- by Reveal Hidden. AUTO.setupFinish forces the full itself when it is done.\n  if AUTO.setup.active then return end\n  if not roomCode or not writeToken then return end\n  -- THE FLIGHT TRACE'S LAST QUESTION (Spectator Tool Autodraw build only,\n  -- 2026-09-17, DEBUG ONLY). A post still in the air turns this one away\n  -- before anything is built -- so nothing is lost here, the hot debt stays\n  -- raised and the next tick tries again. It is said out loud only when a hot\n  -- change IS waiting, because that is the case where a flight can look like\n  -- it went missing.\n  if inFlight then\n    if DEBUG_ENABLED and AUTO.hotPending then\n      print(\"[Spectator] publish gate: inFlight\")\n    end\n    return\n  end\n  -- ... AND WHILE A HAND POST IS IN THE AIR (2026-09-29). AUTO.presSplice\n  -- attaches the hand trail and the hold events only when their flight slots\n  -- are free, so a state post that left while a standalone presence post\n  -- (AUTO.presStandalone) was unanswered carried only the plain `pointers`\n  -- sample, stamped with this post's ts. The held-back trail followed one post\n  -- later, older than that sample, and the site dropped its samples as repeats\n  -- (3 real samples in 53 s, room H6HM); its hold events arrived 0.6-1.2 s late\n  -- (artifacts/cache-layer/drag-analysis-2a/report.md, section 4). Waiting here\n  -- means the trail and the holds always ride the post that follows.\n  --\n  -- The cost, accepted by the user: in that rare overlap a table change -- or a\n  -- forced full -- waits up to one presence round trip. Nothing is lost. An\n  -- ordinary change keeps its debts (the unacknowledged signature,\n  -- AUTO.hotPending, AUTO.fullPending), so pollLoop offers it again on its next\n  -- tick. A FORCED call becomes a full that is owed: nextFullSnapshotAt = 0, the\n  -- \"next publish is a full\" idiom, which pollLoop's fullDue re-tries on every\n  -- tick B until this gate opens (and which holds the hot-only posts back\n  -- meanwhile, as any due full does). Most forced callers set it themselves;\n  -- the one that does not is scheduleRetry's timer, which re-arms on inFlight\n  -- but not on this -- and after a network error nothing else would resend the\n  -- failed post's change, because building that diff has already moved the\n  -- baselines the next one is diffed against.\n  if AUTO.pres ~= nil and AUTO.pres.inFlight then\n    if DEBUG_ENABLED and AUTO.hotPending then\n      print(\"[Spectator] publish gate: presence inFlight\")\n    end\n    if force then nextFullSnapshotAt = 0 end\n    return\n  end\n\n  local g0 = os.clock()\n  local now = os.clock()\n\n  if not force and (now - lastPostAt) < MIN_POST_INTERVAL then\n    if DEBUG_ENABLED then profAdd(\"t_publish_gate\", os.clock() - g0) end\n    return\n  end\n  if not force and lastSeenSig == nil then\n    if DEBUG_ENABLED then profAdd(\"t_publish_gate\", os.clock() - g0) end\n    return\n  end\n  -- ... unless a hot tick is holding a movement that has not gone out. The\n  -- cheap signature is only rebuilt on full ticks, so on a hot tick\n  -- lastSeenSig cannot have moved and this test would otherwise close the\n  -- gate on every hot publish. AUTO.fullPending is the same debt for a\n  -- DEATH (2026-09-08): the object that died may have reached the board\n  -- through a FLIGHT post alone, and only an ordinary diff can take it\n  -- off again.\n  if not force and lastSeenSig == lastAckSig\n     and not AUTO.hotPending and not AUTO.fullPending then\n    if DEBUG_ENABLED then profAdd(\"t_publish_gate\", os.clock() - g0) end\n    return\n  end\n\n  if DEBUG_ENABLED then PROF.publishesAttempted = PROF.publishesAttempted + 1 end\n\n  -- Single builder call: buildPayload returns the assembled body STRING plus its\n  -- type. t_payload times the whole build; the JSON.encode + table.concat share\n  -- is accumulated into t_json / BUILD_JSON_SECS inside the builders so the\n  -- profile still shows the encode cost now that it is spread across fragments.\n  --\n  -- buildPayload spends a seq on its very first line, so a crash ANYWHERE later\n  -- in the build (a dangling zone reference being the usual cause) used to burn a\n  -- seq that never reached the server. The Durable Object then stayed permanently\n  -- one behind and every later diff came back 409. Guard the CALL so a failed\n  -- build costs nothing: no seq, no post, no baseline we cannot account for.\n  local tPayload0 = os.clock()\n  local seqBefore = DIFF_SEQ\n  local okBuild, body, ptype = pcall(buildPayload, force)\n  local tPayload = os.clock() - tPayload0\n  if (not okBuild) or type(body) ~= \"string\" then\n    -- On a pcall failure `body` carries the error message instead of the payload.\n    local err = okBuild and (\"builder returned \" .. type(body)) or tostring(body)\n    -- Hand the seq back. Nothing was posted, so the server is still in step with\n    -- seqBefore and the next successful build reuses this exact number -- no gap.\n    DIFF_SEQ = seqBefore\n    -- A half-finished build may already have mutated per-zone diff baselines\n    -- (buildDiffSnapshot updates LAST_ZONE_STATE as it walks the zones), so the\n    -- next payload MUST be a full: only a full rebuilds the baseline wholesale.\n    -- nextFullSnapshotAt = 0 is the existing \"next publish is a full\" idiom (see\n    -- toggleRevealHidden) and it also makes pollLoop publish on the next tick\n    -- even when the table is still and the cheap signature has not moved.\n    nextFullSnapshotAt = 0\n    -- The overwhelmingly likely cause is a zone reference that died mid-build.\n    ZONES_DIRTY = true\n    if os.clock() >= (nextBuildFailLogAt or 0) then\n      nextBuildFailLogAt = os.clock() + 10.0\n      print(\"[Spectator] payload build FAILED (seq \" .. tostring(seqBefore + 1) ..\n            \" refunded, next publish forced FULL, zones rescanning): \" .. err)\n    end\n    if DEBUG_ENABLED then\n      PROF.publishesErr = PROF.publishesErr + 1\n      profAdd(\"t_payload\", tPayload)\n    end\n    -- Deliberately no scheduleRetry(): pollLoop's own 0.5s tick already retries\n    -- (nextFullSnapshotAt = 0 makes fullDue true), and adding a timer here would\n    -- stack a second forced full on top of it.\n    return\n  end\n\n  -- AN EMPTY DIFF IS NOT WORTH A POST (Spectator Tool Autodraw build only,\n  -- 2026-09-12; hot payloads too since 2026-09-13). It was built, it carries\n  -- nothing, and posting it would cost a DIFF_SEQ and a round trip to tell the\n  -- site what it already has. The ORDINARY case happens on the tick after\n  -- every hot post: the movement the hot payload carried has moved the\n  -- whole-table signature, pollLoop sees that and asks for an ordinary diff,\n  -- and the diff finds the movement already sent. The HOT case happens\n  -- whenever the movers turn out to have nothing new to say -- the sample\n  -- landed on the same rounded pose, or the one thing that moved is excluded\n  -- by the diff's own walk. AUTO.hotPublishable is the cheap pre-check that\n  -- stops most of those before a payload is built at all; this catches the\n  -- rest, after the build, for the same reason.\n  --\n  -- Hand the seq back exactly as the build-failure branch above does, so the\n  -- server stays in step and the next real payload reuses this number. What is\n  -- NOT the same as that branch: nothing is forced FULL and no zone rescan is\n  -- armed, because nothing went wrong -- the build succeeded and its baselines\n  -- (LAST_ZONE_STATE, LAST_HAND_STATE) are correct and complete.\n  --\n  -- THE DEBTS ARE SETTLED PER KIND, and that is the whole of the difference\n  -- between a skipped hot payload and a skipped ordinary one.\n  --\n  -- ALWAYS: the seq, and AUTO.hotPending -- the movers had nothing to say, so\n  -- there is nothing owed on their account; a later sample raises it again.\n  --\n  -- ONLY ON AN ORDINARY DIFF: the acknowledged signature catches up with the\n  -- seen one, because an empty WHOLE-TABLE diff is proof the site's state and\n  -- ours agree; and AUTO.fullPending, because a death with anything left to\n  -- remove would have put a remove in it. An empty HOT payload proves neither:\n  -- it looked at the movers and nothing else -- the same reasoning that makes\n  -- a hot post record postedSig = nil -- and only the whole-table walk emits a\n  -- `remove`, so it cannot carry the one a death is waiting for. Settling\n  -- either of those here would acknowledge a change nobody has sent.\n  --\n  -- Left standing, any debt that IS settleable would re-enter publishIfNeeded\n  -- on the next tick and build the same empty payload for ever.\n  --\n  -- A FULL is never skipped: the flag below is set only by the ORDINARY and\n  -- HOT branch of buildDiffSnapshot -- the full returns long before it -- and\n  -- `not force` is belt and braces on top of that.\n  if AUTO.diffEmpty and not force then\n    DIFF_SEQ = seqBefore\n    AUTO.hotPending = false\n    if not AUTO.hotOnly then\n      lastAckSig = lastSeenSig\n      AUTO.fullPending = false\n    end\n    if DEBUG_ENABLED then\n      PROF.publishesEmpty = PROF.publishesEmpty + 1\n      profAdd(\"t_payload\", tPayload)\n    end\n    return\n  end\n\n  if DEBUG_ENABLED then\n    profAdd(\"t_payload\", tPayload)\n    profSlowPush(\"payload\", \"buildPayload type=\" .. tostring(ptype), tPayload)\n    profSlowPush(\"json\", \"JSON encode share (frag-assembled) type=\" .. tostring(ptype), BUILD_JSON_SECS)\n    print(\"[Spectator] payload type=\" .. tostring(ptype) .. \" bytes=\" .. tostring(#body))\n  end\n\n  local updateUrl = WORKER_BASE .. \"/update/\" .. roomCode\n  local headers = {\n    [\"Content-Type\"] = \"application/json\",\n    [\"Authorization\"] = \"Bearer \" .. writeToken\n  }\n\n  -- FROZEN STATE (Spectator Tool Autodraw build only, 2026-09-07). What this\n  -- post actually carries, remembered HERE rather than read back in the\n  -- callback: the table keeps moving while the post is in flight, so by the\n  -- time the 200 arrives lastSeenSig may describe changes this payload never\n  -- contained -- and acknowledging those is how a change goes missing until\n  -- something else moves.\n  --\n  -- nil on a HOT post, deliberately: that payload carries the moving objects\n  -- and nothing else, so it proves nothing about the rest of the signature.\n  -- Leaving lastAckSig alone keeps the next full tick's flush knocking until a\n  -- full-tick diff has actually gone out.\n  local postedSig = nil\n  if not AUTO.hotOnly then postedSig = lastSeenSig end\n  -- PRESENCE RIDES THIS POST (Spectator Tool Autodraw build only, 2026-09-15):\n  -- the pending pointer frame, the seat departures and the buffered pings are\n  -- appended to the body that is about to go out, for no extra request at all\n  -- (and since 2026-09-29 the hand trail and the hold events; see presence).\n  local presCarried\n  body, presCarried = AUTO.presSplice(body)\n  if DEBUG_ENABLED and presCarried ~= nil then PROF.presPiggy = PROF.presPiggy + 1 end\n  inFlight = true\n  lastPostAt = now\n  -- Whatever this payload is, it was built from the hot objects' current\n  -- positions, so that debt is settled here rather than in the callback.\n  AUTO.hotPending = false\n  -- The DEATH debt, though, is only settled by an ORDINARY post (2026-09-08):\n  -- a hot payload carries the moving objects and nothing else, so it cannot\n  -- contain the `remove` a death is waiting for -- only the whole-table walk\n  -- of a full tick emits one.\n  if not AUTO.hotOnly then AUTO.fullPending = false end\n  if DEBUG_ENABLED then PROF.publishesSent = PROF.publishesSent + 1 end\n\n  WebRequest.custom(updateUrl, \"POST\", true, body, headers, function(req)\n    local cb0 = os.clock()\n    inFlight = false\n    -- ... and the answer for whatever this post carried (2026-09-15). The DO\n    -- relays the presence fields of EVERY authenticated well-formed body before\n    -- it judges the state part, so a 409 (seq gap: the full it asks for goes\n    -- out anyway) has delivered them just as a 200 has -- re-queuing on it\n    -- would replay every ping at the site. On is_error response_code is nil,\n    -- so that and every other answer read false and the events are re-queued\n    -- rather than lost.\n    AUTO.presAck(presCarried, req.response_code == 200 or req.response_code == 409)\n\n    if req.is_error then\n      if DEBUG_ENABLED then PROF.publishesErr = PROF.publishesErr + 1 end\n      print(\"UPDATE error: \" .. tostring(req.error))\n      print(\"UPDATE response: \" .. tostring(req.text))\n      scheduleRetry(true) -- unanswered post: the only kind that spends the budget\n\n      if DEBUG_ENABLED then\n        local cbdt = os.clock() - cb0\n        profAdd(\"t_update_cb\", cbdt)\n        profSlowPush(\"webcb\", \"UPDATE cb (error)\", cbdt)\n      end\n      return\n    end\n\n    -- An HTTP RESPONSE arrived, whatever its status: the worker is reachable, so\n    -- the consecutive-network-error budget is stale by definition. Clearing it\n    -- here (rather than only on 200) is what stops three answered-but-rejected\n    -- posts from permanently exhausting the retry budget.\n    retryAttempts = 0\n\n    if req.response_code == 200 then\n      -- Any 200 is success, including {ok:true, ignored:true} (a retry of an\n      -- already-applied seq). Only non-200 / network error triggers a retry.\n      -- ... and NOT lastSeenSig, which has moved on while this post was in\n      -- flight. nil means \"a hot payload: acknowledge nothing\".\n      if postedSig ~= nil then lastAckSig = postedSig end\n      if DEBUG_ENABLED then PROF.publishesAck200 = PROF.publishesAck200 + 1 end\n\n      if DEBUG_ENABLED then\n        local cbdt = os.clock() - cb0\n        profAdd(\"t_update_cb\", cbdt)\n        profSlowPush(\"webcb\", \"UPDATE cb (200)\", cbdt)\n      end\n      return\n    end\n\n    -- TERMINAL statuses. 404 (room unknown/expired) and 401 (write token\n    -- rejected) are the only two answers that retrying can NEVER fix, because\n    -- the room code and its writeToken are minted solely by POST /create, and\n    -- /create is only ever called by the Broadcast toggle. A room dies 2h after\n    -- its last accepted update (docs/protocol.md) and every /update/<code> after\n    -- that is a 404 forever -- so the normal retry path would force a FULL\n    -- snapshot every couple of seconds until the table is closed, spamming chat\n    -- with \"UPDATE status: 404\" and never recovering. 401 is the same shape of\n    -- problem: a token the DO refuses stays refused until a new room exists.\n    --\n    -- Contrast 409 / 413 / 5xx, which stay on the retry path below: 409\n    -- {needFull:true} is the protocol's DESIGNED self-heal (the DO is alive and\n    -- ASKING for a full), and 413/5xx are transient conditions a later, smaller,\n    -- or luckier post can clear. Those are recoverable; these two are not.\n    --\n    -- We deliberately do NOT auto-create a replacement room here: that would\n    -- silently change the room code and kill every link already shared with\n    -- spectators, with no signal to anyone. Stop, say so, let the human decide.\n    local rc = req.response_code\n    if rc == 404 or rc == 401 then\n      broadcasting = false\n      AUTO.destroy()\n      -- Drop the dead room outright so nothing can post to it again. Both entry\n      -- points (publishIfNeeded, pollLoop) bail on a nil roomCode/writeToken, so\n      -- a retry timer armed before this point exits harmlessly even if the user\n      -- re-enables broadcasting before it fires.\n      roomCode = nil\n      writeToken = nil\n      -- Leave no stuck flags. inFlight/retryAttempts are already clear at this\n      -- point in the callback; setting them here keeps the stop self-contained\n      -- and independent of what runs above. retryPending is the load-bearing one:\n      -- it may well be true, and leaving it set would block the first retry of\n      -- the NEXT broadcast.\n      retryPending = false\n      inFlight = false\n      retryAttempts = 0\n      -- No scheduleRetry() -- that is the entire point of this branch.\n      if rc == 404 then\n        print(\"[Spectator] Room expired (404). Broadcast turned OFF. Press Broadcast to start a new room.\")\n      else\n        print(\"[Spectator] Write token rejected (401). Broadcast turned OFF. Press Broadcast to start a new room.\")\n      end\n      -- Repaint so the panel matches reality: Broadcast back to red/OFF and the\n      -- status label back to \"SPECTATOR 13\" instead of a room code that is gone.\n      setButtonLabels()\n      if DEBUG_ENABLED then\n        PROF.publishesErr = PROF.publishesErr + 1\n        local cbdt = os.clock() - cb0\n        profAdd(\"t_update_cb\", cbdt)\n        profSlowPush(\"webcb\", \"UPDATE cb (terminal \" .. tostring(rc) .. \")\", cbdt)\n      end\n      return\n    end\n\n    -- Non-200. The protocol's designed self-heal is 409 {needFull:true}: the DO\n    -- has no usable baseline for our seq and is ASKING for a full snapshot. That\n    -- is a normal, expected, recoverable condition -- not a failure -- so it must\n    -- force a FULL and must not count against anything (retryAttempts was already\n    -- cleared above, and scheduleRetry(false) leaves it alone).\n    local rtext = tostring(req.text or \"\")\n    local needFull = (req.response_code == 409)\n    if (not needFull) and rtext ~= \"\" then\n      -- Any answered body may carry needFull, not just 409; decode rather than\n      -- substring-match so needFull:false is not mistaken for a request.\n      local okD, data = pcall(function() return JSON.decode(rtext) end)\n      if okD and type(data) == \"table\" and data.needFull then needFull = true end\n    end\n    if needFull then\n      nextFullSnapshotAt = 0 -- next payload re-baselines the DO (see toggleRevealHidden)\n      ZONES_DIRTY = true     -- a gap usually follows a zone edit; rescanning is cheap\n    end\n\n    if DEBUG_ENABLED then PROF.publishesErr = PROF.publishesErr + 1 end\n    print(\"UPDATE status: \" .. tostring(req.response_code) ..\n          (needFull and \" (needFull -> next publish forced FULL)\" or \"\"))\n    print(\"UPDATE response: \" .. rtext)\n    scheduleRetry(false)\n\n    if DEBUG_ENABLED then\n      local cbdt = os.clock() - cb0\n      profAdd(\"t_update_cb\", cbdt)\n      profSlowPush(\"webcb\", \"UPDATE cb (non-200)\", cbdt)\n    end\n  end)\n\n  if DEBUG_ENABLED then profAdd(\"t_publish_gate\", os.clock() - g0) end\nend\n\n-- =========================\n-- SETTING UP  (only in the \"Spectator Tool Autodraw\" build)\n-- =========================\n-- Feature folder: setup/ (tts/lua/setup/README.md has the why).\n-- In short: the first full after Broadcast used to encode every object's\n-- fragment cold in one frame -- about 7.5 ms per object, 1.5 s for the 202 of\n-- room 7UP7 -- and froze the game. That work happens here instead, a slice at a\n-- time, with the publish held back until it is done.\n--\n-- A SLICE PER FRAME, NOT PER POLL (2026-09-07, the second cut). The slice used\n-- to be 20 ms of work on every 0.25 s poll tick, which is one or two dropped\n-- frames four times a second for the whole of setup -- the stutter the user\n-- watched and asked to have spread out. AUTO.setupFrame does the work now,\n-- re-arming itself with Wait.frames, and AUTO.setupStep survives only as a\n-- WATCHDOG: on a poll tick it reads the clock and returns, unless the frame loop\n-- has produced nothing for over a second.\n\n-- Build the work list. ONE zone walk, through exactly the filter the tool\n-- publishes with, so the objects prepared are the objects the first full will\n-- encode -- no more, and none of the hand cards a table-wide auto zone contains.\nAUTO.setupBegin = function()\n  -- The exclusion set is normally rebuilt at the top of each poll tick and no\n  -- tick has run for this room yet, so it is still empty. Without this the tool\n  -- itself and every card in every hand would be counted and prepared for\n  -- nothing, and the percentage would be measured against the wrong total.\n  AUTO.refreshExcl()\n  local list = {}\n  local okZ, zones = pcall(function() return refreshZoneCacheIfNeeded(true) end)\n  if okZ and type(zones) == \"table\" then\n    for _, z in ipairs(zones) do\n      -- The same liveness proof every other zone consumer makes: a dangling\n      -- zone reference throws on the first method call.\n      if zoneAlive(z) then\n        local ok, objs = pcall(function() return AUTO.filtered(z) end)\n        if ok and type(objs) == \"table\" then\n          for _, o in ipairs(objs) do\n            if o then\n              -- The GUID is read ONCE, here, and carried in the list: the\n              -- dead-object test on every later slice is then a hash lookup that\n              -- never touches the reference.\n              local okG, g = pcall(function() return o.getGUID() end)\n              if okG and type(g) == \"string\" and g ~= \"\" then\n                list[#list + 1] = { obj = o, guid = g }\n              end\n            end\n          end\n        end\n      end\n    end\n  end\n  -- THE GENERATION. Every frame callback carries the number the setup it belongs\n  -- to was built with, and compares it before doing anything. Broadcast off and\n  -- straight back on runs a SECOND setupBegin while the first one's callback is\n  -- still pending, and without this the two loops would walk the new list\n  -- together. Seeded from the setup it replaces, so it only ever goes up.\n  local gen = (AUTO.setup and AUTO.setup.gen or 0) + 1\n  -- pct starts at 0 because that is TRUE, not a sentinel: the caller repaints\n  -- the panel straight after this, and \"Setting up... 0%\" is what it should say\n  -- before a single object has been prepared. frameAt starts NOW, so the\n  -- watchdog gives the frame loop a full second to draw its first breath.\n  -- MEASUREMENT (2026-09-07), print-only: over8 / over16 / maxMs / maxFrame are\n  -- the frame histogram AUTO.setupFrameMs keeps, `slow` the five slowest OBJECTS\n  -- AUTO.setupNote keeps, and AUTO.setupFinish prints both. `polls` is already\n  -- the number of WATCHDOG slices -- it is only incremented where one is taken --\n  -- so the summary reports it rather than counting the same thing twice.\n  -- THE LIST'S CAPTURE STAMP (2026-09-13). The walk above is one synchronous\n  -- pass, so ONE stamp describes every reference in it exactly: nothing can have\n  -- died between the first object and the last. Every later slice compares the\n  -- GUID it recorded against this, and touches nothing that died since.\n  AUTO.setup = { active = true, list = list, at = AUTO.deathSeq, i = 1, total = #list,\n                 prepared = 0, polls = 0, frames = 0, pct = 0,\n                 over8 = 0, over16 = 0, maxMs = 0, maxFrame = 0,\n                 slow = {}, frameOpen = false,\n                 started = os.clock(), frameAt = os.clock(), gen = gen }\n  print(\"[Spectator] Setting up: \" .. tostring(#list) .. \" object(s) to prepare.\")\n  -- ... and off it goes. Everything from here happens inside AUTO.setupFrame.\n  AUTO.setupArm(1)\nend\n\n-- ONE object: exactly the per-object work the full's baseline-seed pass does, in\n-- the same order, through the same functions -- seed name/scale/invisibility/xml\n-- AND populate the button cache FIRST so lightObjSig folds both in, THEN encode\n-- the fragment under that signature, which is what puts it in FRAG_CACHE.\n--\n-- The per-zone diff baseline that pass also records is deliberately NOT repeated\n-- here; the module docstring says why (the full rebuilds it wholesale anyway,\n-- and a half-written one is the one state a diff must never be built from).\nAUTO.setupOne = function(o, guid)\n  if not o then return end\n  -- WHERE THE TIME WENT (2026-09-08), print-only: four os.clock reads and one\n  -- table write per object, and no engine call at all. AUTO.setupNote copies the\n  -- numbers onto an object that makes the top five; nothing else ever reads\n  -- them. Cleared FIRST, so an object that raises part-way through cannot leave\n  -- the PREVIOUS object's phases standing for setupNote to copy.\n  AUTO.setupPhase = nil\n  AUTO.setupJson = 0\n  local now = os.clock()\n  ensureMetaSeeded(o, guid, now)\n  local t1 = os.clock()\n  extractButtonsCached(o, true)\n  local t2 = os.clock()\n  encodedZoneItemFor(o, lightObjSig(o, nil), true)\n  local t3 = os.clock()\n  AUTO.setupPhase = { meta = (t1 - now) * 1000, btn = (t2 - t1) * 1000,\n                      frag = (t3 - t2) * 1000, json = AUTO.setupJson or 0 }\nend\n\n-- The end of the warm-up, however it ended. It runs exactly once per setup:\n-- AUTO.setupMark is the only caller, and every path into it (the frame loop, the\n-- watchdog) returns early once active is false, so it cannot be reached twice.\nAUTO.setupFinish = function(short)\n  local s = AUTO.setup\n  local secs = os.clock() - (s.started or os.clock())\n  local prepared, total = s.prepared or 0, s.total or 0\n  -- MEASUREMENT: the frame we are INSIDE is still open (we were reached from its\n  -- slice), so account for it here rather than let the summary below miss the\n  -- one frame most likely to be the worst -- the object that finishes the list\n  -- can perfectly well be the 2330-card bag. Nothing happens on the watchdog\n  -- path, where frameOpen is false.\n  AUTO.setupFrameMs(s, (os.clock() - (s.frameAt or os.clock())) * 1000)\n  s.active = false\n  s.list = nil   -- release the object references; nothing else here is big\n  -- The next publish MUST be a full: it is this room's first payload, so the\n  -- Durable Object has no baseline a diff could apply -- and it is now cheap,\n  -- because every fragment it needs is warm. nextFullSnapshotAt = 0 is the\n  -- existing \"next publish is a full\" idiom (see toggleRevealHidden).\n  nextFullSnapshotAt = 0\n  setButtonLabels()   -- repaints the Broadcast button back to \"Broadcast: ON\"\n  if short then\n    print(\"[Spectator] Setup hit its safety cap -- \" .. tostring(prepared)\n          .. \" of \" .. tostring(total) .. \" object(s) prepared. Publishing anyway.\")\n  end\n  -- The room code, at the moment there is actually a board to look at. Room\n  -- create only says the code is reserved.\n  print(\"Room created. Code: \" .. tostring(roomCode))\n  print(\"[Spectator] Setup done: \" .. tostring(prepared) .. \" of \" .. tostring(total)\n        .. \" object(s) prepared in \" .. string.format(\"%.1f\", secs) .. \" s.\")\n  -- MEASUREMENT (2026-09-07), and printed ALWAYS rather than under DEBUG: the\n  -- whole point is to learn, from the user's own table, whether the stutter is a\n  -- few monstrous objects or the steady slice -- and the user is not running a\n  -- debug build. Two lines, once per broadcast.\n  print(\"[Spectator] Setup frames: \" .. tostring(s.frames or 0) .. \" total, \"\n        .. tostring(s.over8 or 0) .. \" over 8 ms, \"\n        .. tostring(s.over16 or 0) .. \" over 16 ms, longest \"\n        .. string.format(\"%.1f\", s.maxMs or 0) .. \" ms (frame \"\n        .. tostring(s.maxFrame or 0) .. \"), watchdog slices \"\n        .. tostring(s.polls or 0) .. \".\")\n  local slow = s.slow or {}\n  if #slow > 0 then\n    local parts = {}\n    for i = 1, #slow do\n      local e = slow[i]\n      local one = string.format(\"%.1f\", e.ms or 0) .. \" ms \" .. tostring(e.tag or \"\")\n                  .. ' \"' .. tostring(e.name or \"\") .. '\"'\n      if e.qty then one = one .. \" (\" .. tostring(e.qty) .. \")\" end\n      -- ... and WHERE that time went (2026-09-08): meta (ensureMetaSeeded),\n      -- btn (extractButtonsCached) and frag (encodedZoneItemFor), with the\n      -- JSON share of frag in brackets. Whole milliseconds -- this is a line\n      -- to read at a glance, not a measurement to do arithmetic on.\n      if e.frag ~= nil then\n        one = one .. \" [meta \" .. string.format(\"%.0f\", e.meta or 0)\n              .. \" | btn \" .. string.format(\"%.0f\", e.btn or 0)\n              .. \" | frag \" .. string.format(\"%.0f\", e.frag or 0)\n              .. \" (json \" .. string.format(\"%.0f\", e.json or 0) .. \")]\"\n      end\n      parts[#parts + 1] = one\n    end\n    print(\"[Spectator] Setup slowest: \" .. table.concat(parts, \" | \"))\n  end\n  -- THE ENCODER BENCH (2026-09-08), DEBUG ONLY and PRINT-ONLY. Nothing here runs\n  -- unless the user turned Debug on before pressing Broadcast. See AUTO.bench.\n  --\n  -- THE THREE SLOWEST, one line each (2026-09-08, the fifth cut). It benched\n  -- slow[1] alone, and slow[1] on this table is the 396-card deck -- a table of\n  -- thousands of small values, which is the shape the lean encoder is best at.\n  -- The objects right behind it are the XML tiles, whose fragments are mostly\n  -- long STRINGS, and a string is where the two encoders differ most: one gsub\n  -- with a table replacement against a per-character walk. Benching one shape\n  -- and wiring the whole payload path on the answer is how the first swap went\n  -- wrong, so all three now get a line and the shapes can be read side by side.\n  -- `bi`, not `i`: two build guards count AUTO.bench's own pair of three-run\n  -- timing loops by their exact text, and a third loop spelled the same way\n  -- would be counted as one of them.\n  if DEBUG_ENABLED then\n    for bi = 1, 3 do\n      local okB, errB = pcall(AUTO.bench, slow[bi])\n      if not okB then\n        print(\"[Spectator] Encoder bench failed: \" .. tostring(errB))\n      end\n    end\n  end\nend\n\n-- ONE object off the work list, wherever the slice came from. Returns false when\n-- the list is exhausted -- which is what both paths read as \"we are done\" -- and\n-- true when an entry was consumed. Living HERE rather than in each path is what\n-- stops the frame loop and the watchdog from drifting apart.\nAUTO.setupAdvance = function(s)\n  local list = s.list or {}\n  local it = list[s.i]\n  if it == nil then return false end\n  s.i = s.i + 1\n  -- NEVER call a method on an object that has died since the list was built:\n  -- reading a dead reference raises an uncatchable .NET error that no pcall\n  -- can see. The test is on the GUID recorded at list-build time and on the\n  -- stamp the list carries, so nothing has to touch the reference to reach it.\n  if not AUTO.deadRef(it.guid, s.at) then\n    -- One bad object must not abort the whole warm-up.\n    local t0 = os.clock()\n    if pcall(AUTO.setupOne, it.obj, it.guid) then\n      s.prepared = (s.prepared or 0) + 1\n    end\n    -- MEASUREMENT (2026-09-07), print-only. Two clock reads per object and\n    -- nothing else on the fast path: AUTO.setupNote decides in one comparison\n    -- whether this object is worth describing, and only then reads its name.\n    AUTO.setupNote(s, (os.clock() - t0) * 1000, it)\n  end\n  return true\nend\n\n-- THE FIVE SLOWEST OBJECTS, print-only (2026-09-07). We know the warm-up still\n-- stutters and we do not know why: a handful of monsters (every bag and deck\n-- goes through obj.getData() in containerPeekRaw, and this table has a\n-- 2330-card bag and a 396-card deck), or the steady 2-3 ms slice. This answers\n-- the first half of that question, and AUTO.setupFrameMs the second.\n--\n-- THE ENGINE IS ONLY ASKED ABOUT AN OBJECT THAT QUALIFIES. The list is kept\n-- sorted, longest first, so slow[5] is the bar to clear and an object under it\n-- costs one number compare and nothing else -- no getName, no tag, no\n-- getQuantity, on the 200-odd objects that are not interesting. The reads that\n-- ARE made sit in one pcall: this runs over whatever mod-specific object is\n-- currently misbehaving, and a raise here would kill the slice.\nAUTO.setupNote = function(s, ms, it)\n  local slow = s.slow\n  if slow == nil then slow = {}; s.slow = slow end\n  local n = #slow\n  if n >= 5 and ms <= (slow[5].ms or 0) then return end\n  -- `obj` is the reference, kept for the DEBUG-ONLY encoder bench in\n  -- AUTO.setupFinish and read nowhere else. Five references outlive the work\n  -- list AUTO.setupFinish drops, which is the only cost; the bench itself\n  -- touches the first THREE of them, each inside its own pcall, and only when\n  -- that entry's GUID has not died since the list was built.\n  local rec = { ms = ms, guid = it.guid, tag = \"\", name = \"\", obj = it.obj }\n  -- ... plus the phase breakdown AUTO.setupOne just recorded (2026-09-08),\n  -- COPIED rather than read later: the next object overwrites it at once.\n  local ph = AUTO.setupPhase\n  if ph ~= nil then\n    rec.meta = ph.meta; rec.btn = ph.btn; rec.frag = ph.frag; rec.json = ph.json\n  end\n  local o = it.obj\n  pcall(function()\n    rec.tag = tostring(o.tag or \"\")\n    rec.name = safeName(o)\n    -- Bags and decks only: getQuantity() is what makes \"212 ms\" mean something\n    -- for a container, and it answers -1 for everything else.\n    if rec.tag == \"Bag\" or rec.tag == \"Deck\" or rec.tag == \"Infinite\" then\n      rec.qty = o.getQuantity()\n    end\n  end)\n  slow[n + 1] = rec\n  table.sort(slow, function(a, b) return (a.ms or 0) > (b.ms or 0) end)\n  slow[6] = nil   -- at most six entries were ever in here, so this is the trim\nend\n\n-- ONE FRAME's cost, print-only (2026-09-07). 8 ms is \"this frame missed 60 fps\n-- if anything else at all was going on\", 16 ms is \"this frame was dropped\".\n--\n-- s.frameOpen is set by AUTO.setupFrame before its slice and cleared here, which\n-- does two jobs: the frame that ENDS the warm-up is accounted for by\n-- AUTO.setupFinish before it prints (setupFinish is reached from inside the\n-- slice, so the frame's own call comes too late), and the frame loop's later\n-- call for that same frame is then a no-op rather than a double count.\nAUTO.setupFrameMs = function(s, ms)\n  if not s.frameOpen then return end\n  s.frameOpen = false\n  if ms > 8 then s.over8 = (s.over8 or 0) + 1 end\n  if ms > 16 then s.over16 = (s.over16 or 0) + 1 end\n  if ms > (s.maxMs or 0) then s.maxMs = ms; s.maxFrame = s.frames or 0 end\nend\n\n-- The end of a slice, wherever it came from: repaint the percentage if it moved,\n-- then end the warm-up if the list is done or a cap has tripped. `capped` is\n-- whatever cap the CALLER owns -- MAX_FRAMES for the frame loop, MAX_POLLS for\n-- the watchdog -- and the wall-clock net belongs to both, so it is tested here,\n-- in the one place both of them finish a slice.\nAUTO.setupMark = function(s, capped)\n  local MAX_SECONDS = 200\n  local n = #(s.list or {})\n  local done = s.i - 1\n  if done > n then done = n end\n  local pct = 100\n  if n > 0 and done < n then pct = math.floor((done * 100) / n) end\n  if pct < 0 then pct = 0 end\n  if pct ~= s.pct then\n    s.pct = pct\n    -- The label itself is composed in ONE place, setButtonLabels, which reads\n    -- s.pct back. Nothing here knows what the button says.\n    setButtonLabels()\n  end\n  if done >= n or capped or ((os.clock() - (s.started or 0)) >= MAX_SECONDS) then\n    AUTO.setupFinish(done < n)\n  end\nend\n\n-- ONE SLICE: objects until the budget is gone or the list runs out, then the\n-- percentage and the end test. AT LEAST ONE OBJECT however long that object\n-- takes, because the budget is tested AFTER the advance -- so setup can never\n-- stall on a heavy object, and a heavy object goes alone while light ones batch.\n--\n-- A plain function rather than a closure so both callers can pcall it without\n-- building one every frame.\nAUTO.setupSlice = function(s, t0, budgetMs, capped)\n  repeat\n    if not AUTO.setupAdvance(s) then break end\n  until (os.clock() - t0) * 1000 >= budgetMs\n  AUTO.setupMark(s, capped)\nend\n\n-- The ONE place the frame loop is armed -- from setupBegin, from the loop's own\n-- tail and from the watchdog. The generation is read HERE and captured by the\n-- closure, so no caller has to carry it around.\nAUTO.setupArm = function(n)\n  local s = AUTO.setup\n  if not s.active then return end\n  local gen = s.gen\n  Wait.frames(function() AUTO.setupFrame(gen) end, n)\nend\n\n-- THE FRAME LOOP: one slice, then re-arm for `rest` frames' time.\nAUTO.setupFrame = function(gen)\n  local s = AUTO.setup\n  -- Every way this callback can be stale, and every one of them returns WITHOUT\n  -- re-arming -- which is how the loop stops. AUTO.destroy cleared active (the\n  -- Broadcast toggle, the terminal 404/401, a failed room create, onDestroy);\n  -- setupFinish cleared it; broadcasting went off under us; or the generation\n  -- has moved on, so this callback belongs to the loop before this one.\n  if not s.active or s.gen ~= gen or not broadcasting then return end\n  -- How many milliseconds of os.clock time ONE frame's slice may spend. Light\n  -- objects BATCH -- the slice keeps taking them while it is under budget -- and\n  -- a heavy one goes alone, because the budget is tested after the first object.\n  local FRAME_BUDGET_MS = 2\n  -- ... and what the loop aims to AVERAGE. THE REST RULE: a slice that cost\n  -- sliceMs comes back in ceil(sliceMs / TARGET_MS_PER_FRAME) frames, never\n  -- fewer than one. A 2 ms slice is back on the very next frame; a 30 ms one --\n  -- one heavy object that blew straight through the budget -- rests 8 frames. So\n  -- the average load stays near this number however heavy individual objects\n  -- turn out to be, which is the whole point of slicing per frame.\n  local TARGET_MS_PER_FRAME = 4\n  -- Safety net for this path, the way MAX_POLLS is the watchdog's: 12000 frames\n  -- is a little over three minutes at 60 fps, and MAX_SECONDS in setupMark ends\n  -- it sooner on any table where a frame is not cheap.\n  local MAX_FRAMES = 12000\n  -- ... and the ceiling on THE REST RULE (2026-09-07). Uncapped,\n  -- a 300 ms object asks for 75 rest frames -- 1.25 s at 60 fps, longer than the\n  -- watchdog's 1 s stall threshold, so the watchdog would decide the loop had\n  -- died and take over with its own 20 ms slices right after the heaviest object\n  -- on the table. 30 frames is half a second, comfortably inside that. The\n  -- payback the rest was buying is moot for an object like that anyway: nothing\n  -- can average 4 ms a frame across a 300 ms slice.\n  local MAX_REST_FRAMES = 30\n\n  local t0 = os.clock()\n  -- Stamped BEFORE the work, because what the watchdog is really asking is \"did\n  -- the callback fire at all\". The watchdog never stamps it: if Wait.frames is\n  -- dead, EVERY poll must take a slice, not just the first one after the stall.\n  s.frameAt = t0\n  s.frames = (s.frames or 0) + 1\n  -- MEASUREMENT (2026-09-07), print-only: \"a frame of this warm-up is in\n  -- progress and has not been counted yet\". AUTO.setupFinish reads it, so the\n  -- frame that ends the list is in the summary it prints; the call below then\n  -- finds it clear and does nothing.\n  s.frameOpen = true\n  -- The slice is pcall'd AS A WHOLE and the re-arm below runs whatever it\n  -- returned. setupOne already has its own pcall, so this catches the unlikely\n  -- rest -- and one unlucky object must never be able to kill the loop.\n  pcall(AUTO.setupSlice, s, t0, FRAME_BUDGET_MS, (s.frames or 0) >= MAX_FRAMES)\n  AUTO.setupFrameMs(s, (os.clock() - t0) * 1000)\n  -- Re-read it: the slice may have finished the warm-up, and a stop during it\n  -- would have cleared the state under us.\n  s = AUTO.setup\n  if not s.active or s.gen ~= gen then return end\n  local rest = math.min(MAX_REST_FRAMES,\n                        math.max(1, math.ceil(((os.clock() - t0) * 1000) / TARGET_MS_PER_FRAME)))\n  AUTO.setupArm(rest)\nend\n\n-- THE WATCHDOG, and nothing else, since 2026-09-07. The work is driven by\n-- AUTO.setupFrame; this runs on the poll tick and its only job is to notice that\n-- the frame loop has stopped producing -- Wait.frames never fired, or a callback\n-- was lost -- and to carry the work itself until it starts again. While the loop\n-- is healthy a poll costs one clock read and returns.\nAUTO.setupStep = function()\n  local s = AUTO.setup\n  if not s.active then return end\n  -- How long the frame loop may go quiet before the poll tick takes over. The\n  -- longest LEGITIMATE gap is one rest: even a 60 ms object rests 15 frames,\n  -- a quarter of a second at 60 fps. A whole second of silence means the loop\n  -- is not running.\n  local STALL_SECONDS = 1.0\n  if (os.clock() - (s.frameAt or 0)) <= STALL_SECONDS then return end\n  -- The old per-poll budget, unchanged, because this path IS the old path: a\n  -- 20 ms slice every 0.25 s, which walks a 202-object table in about 17 s.\n  local WORK_BUDGET_MS = 20\n  -- Safety cap for this path, the way MAX_FRAMES is the frame loop's: 800 polls\n  -- is 200 s at POLL_SECONDS = 0.25.\n  local MAX_POLLS = 800\n  local t0 = os.clock()\n  s.polls = (s.polls or 0) + 1\n  pcall(AUTO.setupSlice, s, t0, WORK_BUDGET_MS, (s.polls or 0) >= MAX_POLLS)\n  -- Try the frame loop again, under a NEW generation so a callback that was\n  -- merely LATE cannot come back and run a second loop beside the one armed\n  -- here. s.frameAt is deliberately left alone: until a frame callback actually\n  -- fires and stamps it, every poll must keep taking a slice.\n  s = AUTO.setup\n  if s.active then\n    s.gen = (s.gen or 0) + 1\n    AUTO.setupArm(1)\n  end\nend\n-- =========================\n-- THE ENCODER BENCH  (2026-09-08) -- DEBUG ONLY, PRINT ONLY\n-- =========================\n-- WHY IT EXISTS. The lean encoder (AUTO.jenc) was wired into the payload path on\n-- 2026-09-08 and taken straight back out: the next setup put this table's\n-- 396-card deck at json 525 ms against 72 ms for the bundled encoder. The append\n-- was quadratic and is fixed, but this repo cannot time MoonSharp -- the fengari\n-- harnesses prove what an encoder ANSWERS, never what it costs -- so the only\n-- honest way to decide whether the fixed version is worth installing is to time\n-- both, in game, on the objects that actually hurt.\n--\n-- WHAT IT HAS SAID SO FAR. On ONE object (room N1KT) it read 38.8 / 58.4 / 55.2\n-- ms bundled against 22.9 / 22.8 / 22.1 lean, and the encoder went onto the\n-- payload path. On THREE (room 97V0, 2026-09-09) the picture fell apart: Player\n-- Cards 41.2 / 38.8 / 38.2 against 23.6 / 32.8 / 46.0, Tarot Deck 15.7 / 15.8 /\n-- 18.9 against 21.0 / 19.9 / 10.5, Deck (396) 39.7 / 41.8 / 38.6 against 22.2 /\n-- 26.5 / 51.2 -- outputs equal, the lean encoder about 15% faster on average and\n-- swinging by 2x, the bundled one steady. So jencTimed went back to JSON.encode\n-- and AUTO.jenc is now benched and nothing else. Widening the bench from one\n-- object to three is what caught it, which is the whole argument for three.\n--\n-- SO IT IS NOT DONE. It stays because it is the instrument, not the experiment:\n-- an encoder on the payload path with nothing watching it is exactly the state\n-- that produced the 525 ms setup. The rule is that nothing re-wires jencTimed --\n-- in either direction -- without reading this line first.\n--\n-- WHAT IT TIMES: the THREE slowest objects of the warm-up that just finished\n-- (s.slow[1..3]), one line each, every one rebuilt into an item with\n-- itemForObject and encoded three times by each encoder. Three, not one, because\n-- the slowest object on this table is a 396-card deck -- thousands of small\n-- values in nested tables -- and the objects just behind it are the XML tiles,\n-- whose fragments are mostly long STRINGS. A string is where the two encoders\n-- differ most (one gsub with a table replacement against a per-character walk),\n-- so one shape alone cannot answer for the payload path. Three RUNS each because\n-- the first pays for whatever the engine caches; three numbers printed rather\n-- than an average, because a single outlier is exactly the thing worth seeing.\n--\n-- WHAT IT COSTS WHEN DEBUG IS OFF: one boolean test in AUTO.setupFinish. When it\n-- is on: six encodes of each of three objects, once per broadcast, at the moment\n-- the room code is printed -- which is not a frame anybody is looking at.\n--\n-- SAFETY. The whole thing is pcall'd by its caller (an object that misbehaves\n-- must not cost the room code its print) and the object reference is only\n-- touched when the GUID has not died since the warm-up list captured it,\n-- because reading a dead reference raises a .NET error no pcall can see.\nAUTO.bench = function(rec)\n  if rec == nil then return end\n  local o = rec.obj\n  -- `or 0`: an unknown capture stamp is older than every death, so this skips.\n  if o == nil or AUTO.deadRef(rec.guid, AUTO.setup.at or 0) then return end\n  local item = itemForObject(o, true)\n  if item == nil then return end\n  local jm, am = {}, {}\n  local sJ, sA\n  -- `b0`, not the `t0` every other timed block in this file uses: the build\n  -- guards count `local t0 = os.clock()` lines to pin the per-object and\n  -- per-frame measurements, and a third one here would break them.\n  for i = 1, 3 do\n    local b0 = os.clock()\n    sJ = JSON.encode(item)\n    jm[i] = (os.clock() - b0) * 1000\n  end\n  for i = 1, 3 do\n    local b0 = os.clock()\n    sA = AUTO.jenc(item)\n    am[i] = (os.clock() - b0) * 1000\n  end\n  -- DO THE TWO AGREE? The strong answer is \"both decode to the same value\", so\n  -- that is what is tried first. If either decode fails -- or the bundled\n  -- decoder chokes on a payload this size -- the line falls back to comparing\n  -- the two lengths and SAYS SO, rather than quietly claiming more than it\n  -- checked.\n  local word, verdict = \"length equal\", (#sJ == #sA) and \"yes\" or \"no\"\n  local okA, va = pcall(function() return JSON.decode(sJ) end)\n  local okB, vb = pcall(function() return JSON.decode(sA) end)\n  if okA and okB and va ~= nil and vb ~= nil then\n    word = \"outputs equal\"\n    verdict = AUTO.benchEq(va, vb) and \"yes\" or \"no\"\n  end\n  print(\"[Spectator] Encoder bench on \" .. tostring(rec.tag or \"\")\n        .. ' \"' .. tostring(rec.name or \"\") .. '\": JSON.encode '\n        .. string.format(\"%.1f\", jm[1] or 0) .. \" / \"\n        .. string.format(\"%.1f\", jm[2] or 0) .. \" / \"\n        .. string.format(\"%.1f\", jm[3] or 0) .. \" ms | AUTO.jenc \"\n        .. string.format(\"%.1f\", am[1] or 0) .. \" / \"\n        .. string.format(\"%.1f\", am[2] or 0) .. \" / \"\n        .. string.format(\"%.1f\", am[3] or 0) .. \" ms | \"\n        .. word .. \": \" .. verdict)\nend\n\n-- Deep equality for two decoded payloads. NUMBERS COMPARE AT %.14g, which is not\n-- sloppiness: that is exactly the rounding AUTO.jenc documents for a non-integer\n-- (a float32 0.7 widened to a double goes out as 0.69999998807907), so a strict\n-- == would report a difference on almost every object and say nothing about\n-- whether the two encoders agree. Everything else compares exactly, and the key\n-- count is checked both ways so an extra field on either side fails.\nAUTO.benchEq = function(a, b)\n  local ta = type(a)\n  if ta ~= type(b) then return false end\n  if ta == \"number\" then\n    return string.format(\"%.14g\", a) == string.format(\"%.14g\", b)\n  end\n  if ta ~= \"table\" then return a == b end\n  local n = 0\n  for k, av in pairs(a) do\n    if not AUTO.benchEq(av, b[k]) then return false end\n    n = n + 1\n  end\n  for _ in pairs(b) do n = n - 1 end\n  return n == 0\nend\n\n-- =========================\n-- POLL LOOP (CHEAP SIG + ROUND-ROBIN INVALIDATION)\n-- =========================\nlocal function pollLoop()\n  if not broadcasting then return end\n  if not roomCode or not writeToken then return end\n\n  local p0 = os.clock()\n  if DEBUG_ENABLED then PROF.polls = PROF.polls + 1 end\n\n  local now = os.clock()\n\n  -- SETTING UP (Spectator Tool Autodraw build only). While the warm-up runs this\n  -- poll does NOTHING else: it gives the WATCHDOG a look-in and returns. The\n  -- work itself is a slice per FRAME in AUTO.setupFrame, so AUTO.setupStep here\n  -- reads the clock and returns immediately unless the frame loop has gone quiet\n  -- for over a second. Publishing is suppressed as well (see publishIfNeeded),\n  -- because a payload built now would encode every fragment cold in one frame --\n  -- the 1.5 s freeze this whole mechanism exists to remove.\n  if AUTO.setup.active then\n    AUTO.setupStep()\n    if AUTO.setup.active then\n      if DEBUG_ENABLED then\n        local sdt = os.clock() - p0\n        profAdd(\"t_poll_total\", sdt)\n        profSlowPush(\"poll\", \"pollLoop setup \" .. tostring(AUTO.setup.pct or 0) .. \"%\", sdt)\n        debugPrintProfileSummary(false)\n      end\n      return\n    end\n    -- Finished on THIS tick: fall through, so the full it just warmed goes out\n    -- now rather than one POLL_SECONDS later. Only the WATCHDOG can end setup\n    -- inside a tick now. Normally the frame loop finishes BETWEEN ticks and the\n    -- next tick never enters this branch at all -- it sees active == false, runs\n    -- as an ordinary tick, and the publish gate is open by then.\n  end\n\n  -- THE SPLIT SCAN (Spectator Tool Autodraw build only, 2026-09-12). Ticks\n  -- alternate on the same parity the hot ticks used, but NEITHER kind is a cheap\n  -- tick any more, and neither returns early: the table's scan is split between\n  -- them.\n  --\n  --   TICK A (odd)   the IDENTITY pass -- one read of every zone, one getGUID\n  --                  per object, AUTO.excl applied, AUTO.scanG / scanO / scanN\n  --                  filled completely -- and then position, rotation and the\n  --                  gate string for the FIRST half of each zone's list.\n  --   TICK B (even)  the SECOND half, off the references tick A captured, then\n  --                  the whole-table signature assembled out of every gate\n  --                  string. The publish decision runs here and nowhere else.\n  --\n  -- BOTH ticks refresh the exclusion set and the zone cache, sample and expire\n  -- the hot set, publish a hot-only diff if one is owed, and take half the\n  -- round-robin's budget. See AUTO.spreadA for the whole of it.\n  --\n  -- Counted HERE, below the warm-up's early return, so a setup poll never moves\n  -- the parity. AUTO.tick is therefore 1 on the first tick after the warm-up:\n  -- that one is a tick A, which is what has to happen -- nothing has ever been\n  -- scanned yet, so the identity pass has to run before anything can publish.\n  AUTO.tick = (AUTO.tick or 0) + 1\n  local tickB = (AUTO.tick % 2) == 0\n  -- Autodraw: what must not be published is decided ONCE per tick,\n  -- before anything walks a zone, so every consumer in this tick sees\n  -- the same answer. A card that left a hand this tick is published\n  -- from the next one.\n  AUTO.refreshExcl()\n  -- refresh zone cache\n  local z0 = os.clock()\n  refreshZoneCacheIfNeeded(false)\n  local zdt = os.clock() - z0\n  if DEBUG_ENABLED then profAdd(\"t_zonecache\", zdt) end\n\n  -- SAMPLE AND EXPIRE, ON EVERY TICK (Spectator Tool Autodraw build only,\n  -- 2026-09-12). A moving object is therefore read four times a second: twice by\n  -- the half-scans (its half is read every 0.5 s) and twice here. With the Hot\n  -- ticks button OFF this only EXPIRES, on both ticks, and movement is sampled by\n  -- the half-scans alone -- every 0.5 s, which is the cadence everything else\n  -- has.\n  AUTO.hotStep(now, AUTO.HOT_TICKS)\n\n  -- PRESENCE (Spectator Tool Autodraw build only, 2026-09-15). One walk of the\n  -- seated players -- twelve pointer reads at the very most -- on every tick, so\n  -- a cursor is sampled four times a second exactly as movement is. ABOVE the\n  -- hot-only publish on purpose: a sample taken now can ride that post instead\n  -- of waiting for a request of its own.\n  AUTO.presSample(now)\n\n  -- THE TABLE'S INK (Spectator Tool Autodraw build only, 2026-09-18). ONE\n  -- Global.getVectorLines() every AUTO.draw.ticks ticks -- 2 s on a scribble,\n  -- stretched to as much as 15 s when a mural makes the read expensive -- and\n  -- NEVER on the publishing tick: `tickB` is passed in and AUTO.drawScan returns\n  -- on it, so the read lands on the cheaper tick and its finding is published\n  -- from the next tick B, a quarter of a second later. That is the same argument\n  -- THE BIG DECK ROSTER's tick-A placement makes. Nothing per object, nothing in\n  -- lightObjSig. See TABLE DRAWINGS: THE INK in the AUTO block.\n  AUTO.drawScan(now, tickB)\n\n  -- ... and the hot-only publish, under exactly the conditions the hot tick used\n  -- before the scan was split. A hot payload must never be a FULL --\n  -- AUTO.filtered answers with the movers alone while hotOnly is set -- so it is\n  -- skipped when one is due, and never posted empty: AUTO.hotPublishable is the\n  -- cheap pre-check (COULD anything be sent -- is any hot entry live, included\n  -- and in a zone) and, since 2026-09-13, publishIfNeeded's own empty skip is the\n  -- answer to WOULD anything be sent. Either way AUTO.hotPending is settled and\n  -- nothing is lost: the next tick B's signature sees the movement anyway.\n  if AUTO.hotPending and DIFF_ENABLED and not AUTO.setup.active\n     and nextFullSnapshotAt ~= 0\n     and (os.clock() + AUTO.HOT_FULL_MARGIN) < nextFullSnapshotAt then\n    if AUTO.hotPublishable() then\n      AUTO.hotOnly = true\n      -- pcall for ONE reason: hotOnly must be false again on every path out of\n      -- here. Left set, the next FULL would be built from the hot filter.\n      pcall(publishIfNeeded, false)\n      AUTO.hotOnly = false\n    else\n      AUTO.hotPending = false\n    end\n  end\n\n  -- rebuild RR lists occasionally -- on tick A only, so the rebuild and the\n  -- identity pass that depends on nothing but the zones land in the same tick.\n  if (not tickB) and now >= (nextRRRebuildAt or 0) then\n    rebuildRoundRobinLists()\n    nextRRRebuildAt = now + RR_REBUILD_SECONDS\n    -- ... and the ONE place a death record is ever dropped (2026-09-13). Here\n    -- because the rebuild has just moved AUTO.rrAt up, which is what usually\n    -- makes an old record droppable.\n    AUTO.deathPrune()\n  -- ... or, on any OTHER tick A, THE BIG DECK ROSTER (2026-09-17).\n  --\n  -- TICK A, for two reasons. Tick B is the expensive half in steady state --\n  -- the second half-scan, the assembled signature, the ordinary diff and the\n  -- post, 15-22 ms of it -- and it is the only tick that can be DELAYED by\n  -- adding work, because it is the only one that publishes. And the re-emit a\n  -- read triggers is consumed by that ordinary diff, so a read made here\n  -- reaches the site on the very next tick, a quarter of a second later.\n  --\n  -- NOT on the tick A that rebuilt above: that one already carries the 10 s\n  -- round-robin rebuild and the death prune, and the roster read is 25 ms. It\n  -- costs a dirty record half a second, once every ten.\n  elseif not tickB then\n    AUTO.rosterService(now)\n  end\n\n  -- RR invalidate caches\n  local rr0 = os.clock()\n  rrStepButtons()\n  rrStepPeeks()\n  local rrdt = os.clock() - rr0\n  if DEBUG_ENABLED then profAdd(\"t_rr_steps\", rrdt) end\n\n  -- build signature, HALF A TICK AT A TIME (Spectator Tool Autodraw build only,\n  -- 2026-09-12). t_sig measures all of it -- the identity pass and the first\n  -- half on tick A, the second half and the assembly on tick B -- so the\n  -- profile's sig= is still the whole of what scanning the table costs, just\n  -- spread over two ticks.\n  local s0 = os.clock()\n  local sig\n  if tickB then\n    AUTO.spreadB()\n    sig = AUTO.spreadSignature()\n  else\n    AUTO.spreadA()\n  end\n  local sdt = os.clock() - s0\n  if DEBUG_ENABLED then profAdd(\"t_sig\", sdt) end\n\n  -- TICK A NEVER PUBLISHES AN ORDINARY DIFF (Spectator Tool Autodraw build\n  -- only). Half the table's gate strings are a tick old at this point, and --\n  -- far more to the point -- the scan arrays were only just rebuilt, so a diff\n  -- here would be comparing a fresh snapshot against signatures computed under\n  -- the old one.\n  -- Everything tick A found goes out from tick B, a quarter of a second later.\n  -- The hot post above is not affected: it carries the movers and is built\n  -- through AUTO.filtered's hot branch, not through the scan arrays.\n  if not tickB then\n    -- The presence slot for a tick that publishes nothing (2026-09-15). It only\n    -- sends when no STATE post has gone for half a second, so on a busy table\n    -- this is a handful of comparisons and a return.\n    AUTO.presStandalone(now)\n    if DEBUG_ENABLED then\n      local adt = os.clock() - p0\n      profAdd(\"t_poll_total\", adt)\n      profSlowPush(\"poll\", \"pollLoop A\", adt)\n      debugPrintProfileSummary(false)\n    end\n    return\n  end\n\n  -- SPECTATOR VIEW (Spectator Tool Autodraw build only, 2026-09-28): a new host\n  -- is a new room under the same code. Read here, on the publishing tick and\n  -- above its decision, so a change found now goes out as a full on this very\n  -- tick: AUTO.viewerHostCheck sets nextFullSnapshotAt = 0, which makes fullDue\n  -- below true. One Player.getPlayers() per 0.5 s.\n  AUTO.viewerHostCheck()\n\n  -- Publish only on a real cheap-signature change, plus a periodic full\n  -- re-baseline every FULL_SNAPSHOT_SECONDS (no forced-publish tick in v13).\n  local fullDue = (nextFullSnapshotAt == 0) or (now >= (nextFullSnapshotAt or 0))\n\n  if sig ~= lastSeenSig then\n    lastSeenSig = sig\n    -- buildDiffSnapshot emits a full by itself when the timer is due.\n    publishIfNeeded(false)\n  elseif fullDue then\n    -- Nothing changed but the periodic full is due -> force it.\n    publishIfNeeded(true)\n  elseif lastSeenSig ~= lastAckSig or AUTO.hotPending or AUTO.fullPending then\n    -- FROZEN STATE (Spectator Tool Autodraw build only, 2026-09-07). An EARLIER\n    -- tick saw this change and moved lastSeenSig, but publishIfNeeded turned it\n    -- away -- the post interval had not elapsed, or a post was still in flight.\n    -- Without this branch the change is never offered again: the signature does\n    -- not move a second time, so the test above stays false until something else\n    -- on the table moves, or the 600 s full comes round. That is the token that\n    -- stays invisible, or freezes mid-flight, until you nudge a card.\n    --\n    -- It cannot loop: publishIfNeeded's own \"lastSeenSig == lastAckSig\" gate\n    -- closes the moment the post is acknowledged, and the 200 callback\n    -- acknowledges exactly what was sent (postedSig), never what has changed\n    -- since. AUTO.hotPending is the same debt owed by a hot tick whose publish\n    -- was blocked, and AUTO.fullPending the debt a DEATH owes: only an ordinary,\n    -- whole-table diff emits a `remove`, so an object a FLIGHT post put on the\n    -- board and that then went into a bag needs one of those to take it off\n    -- again (2026-09-08).\n    publishIfNeeded(false)\n  end\n\n  -- ... and the same slot on tick B, AFTER the publish decision above, so a\n  -- state post always gets first refusal on carrying the presence fields\n  -- (2026-09-15).\n  AUTO.presStandalone(now)\n\n  local pdt = os.clock() - p0\n  if DEBUG_ENABLED then\n    profAdd(\"t_poll_total\", pdt)\n    profSlowPush(\"poll\", \"pollLoop B\", pdt)\n    debugPrintProfileSummary(false)\n  end\nend\n\n-- =========================\n-- DEBUG BUTTONS (unchanged behavior)\n-- =========================\nlocal function debugToggleProfiling()\n  DEBUG_ENABLED = not DEBUG_ENABLED\n  if DEBUG_ENABLED then\n    print(\"[Spectator] DEBUG ENABLED (profiling). Printing every \" .. tostring(DEBUG_LOG_SECONDS) .. \"s.\")\n    profResetWindow()\n    PROF.nextLogAt = os.clock() + DEBUG_LOG_SECONDS\n  else\n    print(\"[Spectator] DEBUG DISABLED (profiling).\")\n  end\nend\n\nlocal function debugPrintNow()\n  debugPrintProfileSummary(true)\nend\n\n-- =========================\n-- UI BUTTONS\n-- =========================\n-- Floating control panel layout. Buttons are placed in the object's local XZ\n-- plane: button `width`/`height` params are in 1/500ths of a local unit, so a\n-- button of width W local units uses width = W*500. Rotation {0,180,0} keeps\n-- width along local X and height along local Z (text orientation is correct\n-- with this rotation). All buttons share the same y, safely above the block.\nlocal BTN_Y   = 0.75\nlocal BTN_ROT = {0, 180, 0}\n\n-- State colors (RGB 0-1) for editButton color / font_color.\nlocal COL_GREEN       = {0.15, 0.55, 0.25}\nlocal COL_RED         = {0.55, 0.15, 0.15}\nlocal COL_ORANGE      = {0.8,  0.5,  0.1}\nlocal COL_BLUE        = {0.2,  0.35, 0.7}\nlocal COL_GRAY        = {0.25, 0.25, 0.28}\nlocal COL_WHITE       = {1, 1, 1}\nlocal COL_STATUS_IDLE = {0.9, 0.9, 0.95}\nlocal COL_STATUS_CODE = {1.0, 0.85, 0.2}\n\n-- No-op click handler for the text-only status label (index 5).\nfunction noop() end\n\n-- NB: `function`, not `local function` -- this ASSIGNS to the forward-declared\n-- local near the top of the file so publishIfNeeded's callback can repaint the\n-- panel. Making it `local function` again would create a second, separate local\n-- and the callback would break.\nfunction setButtonLabels()\n  -- Status label (index 5): idle name vs. large broadcast room code.\n  if broadcasting then\n    self.editButton({\n      index = 5, label = \"CODE: \" .. (roomCode or \"....\"),\n      font_size = 240, font_color = COL_STATUS_CODE\n    })\n  else\n    self.editButton({\n      index = 5, label = \"SPECTATOR 13\",\n      font_size = 200, font_color = COL_STATUS_IDLE\n    })\n  end\n\n  -- Broadcast toggle (index 0): orange \"Setting up... NN%\" while the warm-up\n  -- runs (Spectator Tool Autodraw build only), then green ON / dark red OFF.\n  -- The percentage is composed HERE and nowhere else -- AUTO.setupStep only moves\n  -- AUTO.setup.pct and asks for a repaint.\n  if broadcasting and AUTO.setup.active then\n    self.editButton({ index = 0,\n      label = \"Setting up... \" .. tostring(AUTO.setup.pct or 0) .. \"%\",\n      color = COL_ORANGE, font_color = COL_WHITE })\n  elseif broadcasting then\n    self.editButton({ index = 0, label = \"Broadcast: ON\",\n      color = COL_GREEN, font_color = COL_WHITE })\n  else\n    self.editButton({ index = 0, label = \"Broadcast: OFF\",\n      color = COL_RED, font_color = COL_WHITE })\n  end\n\n  -- Reveal Hidden toggle (index 1): green ON / orange OFF.\n  if REVEAL_HIDDEN then\n    self.editButton({ index = 1, label = \"Reveal Hidden: ON\",\n      color = COL_GREEN, font_color = COL_WHITE })\n  else\n    self.editButton({ index = 1, label = \"Reveal Hidden: OFF\",\n      color = COL_ORANGE, font_color = COL_WHITE })\n  end\n\n  -- Rescan Zones (index 2): neutral.\n  self.editButton({ index = 2, label = \"Rescan Zones\",\n    color = COL_GRAY, font_color = COL_WHITE })\n\n  -- Debug toggle (index 3): blue ON / neutral OFF.\n  if DEBUG_ENABLED then\n    self.editButton({ index = 3, label = \"Debug: ON\",\n      color = COL_BLUE, font_color = COL_WHITE })\n  else\n    self.editButton({ index = 3, label = \"Debug: OFF\",\n      color = COL_GRAY, font_color = COL_WHITE })\n  end\n\n  -- Print Profile (index 4): neutral.\n  self.editButton({ index = 4, label = \"Print Profile\",\n    color = COL_GRAY, font_color = COL_WHITE })\n\n  -- Hot ticks toggle (index 6): green ON / orange OFF (Spectator Tool Autodraw\n  -- build only, 2026-09-09). ON is the normal state -- movement sampled every\n  -- 0.25 s -- so OFF is the one that has to be obvious on the panel, and orange\n  -- is what this panel already uses for \"deliberately not the default\".\n  if AUTO.HOT_TICKS then\n    self.editButton({ index = 6, label = \"Hot ticks: ON\",\n      color = COL_GREEN, font_color = COL_WHITE })\n  else\n    self.editButton({ index = 6, label = \"Hot ticks: OFF\",\n      color = COL_ORANGE, font_color = COL_WHITE })\n  end\n\n  -- Hand zones toggle (index 7): green ON / orange OFF (Spectator Tool Autodraw\n  -- build only, 2026-09-18). ON is the default -- the hand zones are drawn, and\n  -- with Reveal Hidden on so are the cards in them -- so OFF is the state that\n  -- has to be obvious on the panel, and orange is what this panel already uses\n  -- for \"deliberately not the default\".\n  if AUTO.HAND_ZONES then\n    self.editButton({ index = 7, label = \"Hand zones: ON\",\n      color = COL_GREEN, font_color = COL_WHITE })\n  else\n    self.editButton({ index = 7, label = \"Hand zones: OFF\",\n      color = COL_ORANGE, font_color = COL_WHITE })\n  end\n\n  -- Spectator View toggle (index 8): green \"Sees as Player\" / orange \"Sees as\n  -- Grey Spectator\" (Spectator Tool Autodraw build only, 2026-09-28). Player is\n  -- the default, so Grey is the state that has to be obvious, and orange is this\n  -- panel's \"deliberately not the default\". Two lines, because one line of 38\n  -- characters is about twice the width of the button at this panel's font.\n  if AUTO.viewMode == \"grey\" then\n    self.editButton({ index = 8, label = \"Spectator View:\\nSees as Grey Spectator\",\n      color = COL_ORANGE, font_color = COL_WHITE })\n  else\n    self.editButton({ index = 8, label = \"Spectator View:\\nSees as Player\",\n      color = COL_GREEN, font_color = COL_WHITE })\n  end\nend\n\nfunction toggleBroadcast()\n  if broadcasting then\n    broadcasting = false\n    AUTO.destroy()\n    setButtonLabels()\n    print(\"[Spectator] DISABLED.\")\n    return\n  end\n\n  broadcasting = true\n  setButtonLabels()\n  print(\"[Spectator] Enabling... creating room...\")\n  -- BEFORE the room is created, so the zone exists by the time the create\n  -- callback runs its first zone scan.\n  AUTO.spawn()\n\n  doCreateRoom(function(ok)\n    if not ok then\n      print(\"[Spectator] Failed to create room. Turning OFF.\")\n      broadcasting = false\n      AUTO.destroy()\n      setButtonLabels()\n      return\n    end\n\n    lastSeenSig = nil\n    lastAckSig  = nil\n    inFlight = false\n    retryAttempts = 0\n    retryPending = false\n    lastPostAt = 0\n    FRAG_CACHE = {}  -- fresh broadcast: no stale fragments carry over\n    SHUFFLE_EPOCH = {}  -- and no stale shuffle epochs\n\n    refreshZoneCacheIfNeeded(true)\n    rebuildRoundRobinLists()\n\n    local now = os.clock()\n    nextRRRebuildAt = now + RR_REBUILD_SECONDS\n\n    -- SETTING UP (Spectator Tool Autodraw build only): warm every fragment over\n    -- the next few seconds instead of building them all cold inside the first\n    -- full. Publishing stays suppressed until this finishes, and finishing is\n    -- what forces that first full.\n    AUTO.setupBegin()\n    setButtonLabels()\n\n    Wait.time(function()\n      if not broadcasting then return end\n      pollLoop()\n      publishIfNeeded(true) -- first publish forces a full snapshot (a no-op while\n                            -- the warm-up runs; AUTO.setupFinish forces it instead)\n    end, FIRST_PUBLISH_DELAY)\n  end)\nend\n\nfunction toggleRevealHidden()\n  REVEAL_HIDDEN = not REVEAL_HIDDEN\n  setButtonLabels()\n  print(\"[Spectator] REVEAL_HIDDEN = \" .. tostring(REVEAL_HIDDEN))\n  -- REVEAL_HIDDEN changes item CONTENT (redaction), so every cached fragment is\n  -- now invalid; drop them regardless of broadcasting state.\n  FRAG_CACHE = {}\n  SHUFFLE_EPOCH = {}  -- symmetric reset (reveal doesn't reorder, but keep it simple)\n  -- ... and the exclusion set, because the HAND ZONES rule reads REVEAL_HIDDEN\n  -- (Spectator Tool Autodraw build only, 2026-09-18): with both switches on the\n  -- hand cards belong ON the board. The full below is built right here, before\n  -- any tick could rebuild the set, so without this it would publish under the\n  -- answer that has just stopped being true.\n  AUTO.refreshExcl()\n  if broadcasting then\n    -- Force a full snapshot so spectators converge on the new visibility.\n    nextFullSnapshotAt = 0\n    publishIfNeeded(true)\n  end\nend\n\nfunction rescanZonesButton()\n  refreshZoneCacheIfNeeded(true)\n  rebuildRoundRobinLists()\n  print(\"[Spectator] 3DText tracked: \" .. tostring(scanTexts()))\n  print(\"[Spectator] Zone cache refreshed. Zones=\" .. tostring(#(CACHED_ZONES or {})) ..\n        \" | RR_OBJECTS=\" .. tostring(#RR_OBJECTS) ..\n        \" | RR_CONTAINERS=\" .. tostring(#RR_CONTAINERS))\nend\n\nfunction toggleDebugProfilingButton()\n  debugToggleProfiling()\n  setButtonLabels()\nend\n\nfunction printProfileButton() debugPrintNow() end\n\n-- HOT TICKS ON / OFF (Spectator Tool Autodraw build only, 2026-09-09). Flip\n-- the flag, repaint through the ONE place the panel's labels are composed, and\n-- say in one line which way it now is. The whole point of the button is to let\n-- the user feel the 0.25 s sampling switch off while dragging something, so the\n-- console has to state what changed rather than just that something did.\n--\n-- Nothing else is touched: no cache is dropped and no publish is forced. The\n-- flag is read in exactly one place -- since the scan was split that is the one\n-- AUTO.hotStep call, which every tick makes: OFF means the hot set is expired\n-- and never sampled, so movement reaches the site from the half-scans alone,\n-- every 0.5 s. There is no longer a tick that can be skipped whole, because\n-- each one carries half of the table's scan.\nfunction toggleHotTicks()\n  AUTO.HOT_TICKS = not AUTO.HOT_TICKS\n  setButtonLabels()\n  if AUTO.HOT_TICKS then\n    print(\"[Spectator] Hot ticks ON: movement is sampled every 0.25 s.\")\n  else\n    print(\"[Spectator] Hot ticks OFF: movement is sampled every 0.5 s by the half-scans.\")\n  end\nend\n\n-- HAND ZONES ON / OFF (Spectator Tool Autodraw build only, 2026-09-18). Flip\n-- the switch, repaint through the ONE place the panel's labels are composed, and\n-- say in one line what the site will now show -- including the Reveal Hidden\n-- half of the rule, because ON with Reveal Hidden OFF draws the boxes and no\n-- cards, and somebody who has just pressed this deserves to be told that rather\n-- than left wondering where the cards are.\n--\n-- Unlike the Hot ticks switch this changes item CONTENT -- cards appear on or\n-- vanish from the board -- so it does what toggleRevealHidden does, in this\n-- order: drop every cached fragment, forget what the room has been told, force\n-- the zone cache to rescan (which is the ONE place the geometry is read, so this\n-- is how it is re-read now rather than in up to ten seconds), rebuild the\n-- exclusion set the hand cards ride on, and -- if a broadcast is running --\n-- re-baseline the room with a full. Every one of those five has to happen BEFORE\n-- the publish, which is why the publish is last.\nfunction toggleHandZones()\n  AUTO.HAND_ZONES = not AUTO.HAND_ZONES\n  setButtonLabels()\n  if AUTO.HAND_ZONES then\n    print(\"[Spectator] Hand zones ON: the boxes are drawn\"\n          .. (REVEAL_HIDDEN and \", and the cards in them.\"\n              or \" -- the cards need Reveal Hidden ON too.\"))\n  else\n    print(\"[Spectator] Hand zones OFF: no boxes, and hand cards leave the board.\")\n  end\n  FRAG_CACHE = {}\n  SHUFFLE_EPOCH = {}\n  AUTO.handZonesSent = \"\"\n  refreshZoneCacheIfNeeded(true)\n  AUTO.refreshExcl()\n  if broadcasting then\n    nextFullSnapshotAt = 0\n    publishIfNeeded(true)\n  end\nend\n\n-- SPECTATOR VIEW: SEES AS PLAYER / SEES AS GREY SPECTATOR (Spectator Tool\n-- Autodraw build only, 2026-09-28). Flip the mode, repaint through the ONE\n-- place the panel's labels are composed, and say in one line what the site will\n-- now show.\n--\n-- NO FULL IS FORCED, unlike Reveal Hidden and Hand zones: nothing the tool puts\n-- in an item changes. The Worker strips what the viewer may not see, and it\n-- sends every spectator a fresh filtered full itself when a payload changes the\n-- viewer. So the room is owed ONE post carrying the new `viewer`, and two lines\n-- say so: the sent signature is cleared, so the next payload carries the field,\n-- and AUTO.fullPending is raised -- the \"one ordinary diff owed\" debt that\n-- pollLoop's third branch and publishIfNeeded's gate already honour (AUTO.forceEmit\n-- raises it for the same reason). Without it a still table posts nothing, and\n-- the flip would wait for the next move or the 600 s full. The first ordinary\n-- post settles it, as it always does.\nfunction toggleSpectatorView()\n  if AUTO.viewMode == \"grey\" then\n    AUTO.viewMode = \"player\"\n  else\n    AUTO.viewMode = \"grey\"\n  end\n  setButtonLabels()\n  if AUTO.viewMode == \"grey\" then\n    print(\"[Spectator] Spectator View: Sees as Grey Spectator -- the site shows\"\n          .. \" what a Grey spectator in TTS sees.\")\n  else\n    print(\"[Spectator] Spectator View: Sees as Player -- the site shows what the\"\n          .. \" host sees.\")\n  end\n  AUTO.viewerSent = \"\"\n  AUTO.fullPending = true\nend\n\nfunction onLoad()\n  -- NOTE: createButton ignores any `index` param; TTS assigns button indexes\n  -- sequentially from 0 in CREATION ORDER. The creation order below must\n  -- match the indexes used by setButtonLabels' editButton calls:\n  --   0=Broadcast, 1=Reveal Hidden, 2=Rescan, 3=Debug, 4=Print, 5=status label,\n  --   6=Hot ticks, 7=Hand zones, 8=Spectator View.\n\n  -- Broadcast toggle (created 1st -> index 0): full width, z = -0.7.\n  self.createButton({\n    click_function = \"toggleBroadcast\", function_owner = self,\n    label = \"Broadcast: OFF\",\n    position = {0, BTN_Y, -0.7}, rotation = BTN_ROT,\n    width = 2000, height = 500, font_size = 200,\n    color = COL_RED, font_color = COL_WHITE,\n    tooltip = \"Start/stop sending game state to the spectator site\"\n  })\n\n  -- Reveal Hidden toggle (created 2nd -> index 1): full width, z = 0.45.\n  self.createButton({\n    click_function = \"toggleRevealHidden\", function_owner = self,\n    label = \"Reveal Hidden: OFF\",\n    position = {0, BTN_Y, 0.45}, rotation = BTN_ROT,\n    width = 2000, height = 500, font_size = 200,\n    color = COL_ORANGE, font_color = COL_WHITE,\n    tooltip = \"When ON, face-down cards and container contents are visible to spectators\"\n  })\n\n  -- Bottom row, z = 1.6: Rescan (3rd -> 2), Debug (4th -> 3), Print (5th -> 4).\n  self.createButton({\n    click_function = \"rescanZonesButton\", function_owner = self,\n    label = \"Rescan Zones\",\n    position = {-1.4, BTN_Y, 1.6}, rotation = BTN_ROT,\n    width = 600, height = 450, font_size = 120,\n    color = COL_GRAY, font_color = COL_WHITE,\n    tooltip = \"Re-detect SpectatorTool scripting zones\"\n  })\n\n  self.createButton({\n    click_function = \"toggleDebugProfilingButton\", function_owner = self,\n    label = \"Debug: OFF\",\n    position = {0, BTN_Y, 1.6}, rotation = BTN_ROT,\n    width = 600, height = 450, font_size = 120,\n    color = COL_GRAY, font_color = COL_WHITE,\n    tooltip = \"Toggle profiling instrumentation (adds overhead)\"\n  })\n\n  self.createButton({\n    click_function = \"printProfileButton\", function_owner = self,\n    label = \"Print Profile\",\n    position = {1.4, BTN_Y, 1.6}, rotation = BTN_ROT,\n    width = 600, height = 450, font_size = 120,\n    color = COL_GRAY, font_color = COL_WHITE,\n    tooltip = \"Print profiling stats to chat\"\n  })\n\n  -- Status label (created 6th -> index 5): text-only (width/height 0),\n  -- top of the panel.\n  self.createButton({\n    click_function = \"noop\", function_owner = self,\n    label = \"SPECTATOR 13\",\n    position = {0, BTN_Y, -1.75}, rotation = BTN_ROT,\n    width = 0, height = 0, font_size = 200,\n    font_color = COL_STATUS_IDLE\n  })\n\n  -- Hot ticks toggle (created 7th -> index 6; Spectator Tool Autodraw build\n  -- only, 2026-09-09): full width, z = 2.75, one row below Rescan / Debug /\n  -- Print, on the same 1.15 spacing the rows above use. CREATED LAST on\n  -- purpose -- see the note at the top of onLoad: the index is the creation\n  -- order, so putting it anywhere else would renumber the five buttons\n  -- setButtonLabels edits.\n  self.createButton({\n    click_function = \"toggleHotTicks\", function_owner = self,\n    label = \"Hot ticks: ON\",\n    position = {0, BTN_Y, 2.75}, rotation = BTN_ROT,\n    width = 2000, height = 500, font_size = 200,\n    color = COL_GREEN, font_color = COL_WHITE,\n    tooltip = \"OFF stops the extra 0.25s movement sampling; the two half-scans still read every object every 0.5s\"\n  })\n\n  -- Hand zones toggle (created 8th -> index 7; Spectator Tool Autodraw build\n  -- only, 2026-09-18): full width, z = 3.90 -- one row below Hot ticks on the\n  -- same 1.15 spacing every row above uses. CREATED LAST, for the reason the\n  -- note at the top of onLoad gives: the index IS the creation order, so putting\n  -- it anywhere else would silently renumber the six buttons setButtonLabels\n  -- edits. The panel already floats clear of the tool's own face -- the rows run\n  -- from z = -1.75 to z = 2.75 on a block a couple of units across -- so another\n  -- full-width row at 3.90 is more of the same rather than something new.\n  self.createButton({\n    click_function = \"toggleHandZones\", function_owner = self,\n    label = \"Hand zones: ON\",\n    position = {0, BTN_Y, 3.90}, rotation = BTN_ROT,\n    width = 2000, height = 500, font_size = 200,\n    color = COL_GREEN, font_color = COL_WHITE,\n    tooltip = \"Draw the players' hand zones -- and, with Reveal Hidden ON, the cards in them\"\n  })\n\n  -- Spectator View toggle (created 9th -> index 8; Spectator Tool Autodraw\n  -- build only, 2026-09-28): full width, z = 5.05 -- one row below Hand zones on\n  -- the same 1.15 spacing. CREATED LAST, for the reason the note at the top of\n  -- onLoad gives. Its label is two lines at a smaller font, because one line of\n  -- \"Spectator View: Sees as Grey Spectator\" at 200 is about twice the button's\n  -- width.\n  self.createButton({\n    click_function = \"toggleSpectatorView\", function_owner = self,\n    label = \"Spectator View:\\nSees as Player\",\n    position = {0, BTN_Y, 5.05}, rotation = BTN_ROT,\n    width = 2000, height = 500, font_size = 150,\n    color = COL_GREEN, font_color = COL_WHITE,\n    tooltip = \"What spectators on the site see of UI meant for some players only: as Player, what the host sees; as Grey, what a Grey spectator sees\"\n  })\n\n  setButtonLabels()\n  print(\"[Spectator] Loaded.\")\n  -- WHICH COPY IS RUNNING (Spectator Tool Autodraw build only,\n  -- 2026-09-08). TTS keeps the script an object was spawned with, so a\n  -- copy already in the scene goes on running the OLD one however many\n  -- times this file is rebuilt. The stamp names the CONTENT (an 8-hex\n  -- SHA-1 of this script with this line removed), the time that content\n  -- was first built, and the size of this script -- all three fixed at\n  -- build time by tts/build/stamp.mjs. Two builds with the same\n  -- hash are the same script, whatever the file dates say.\n  print(\"[Spectator] Build 2026-09-30 03:23Z (ed3f8f4b), 456192 chars\")\n  print(\"  - Draw scripting zones and rename to '\" .. ZONE_NAME_PREFIX .. \":YourZoneName'\")\n  print(\"  - Leave zone Tags EMPTY so zone.getObjects() returns everything.\")\n  print(\"  - Debug profiling can add overhead; toggle it ON only when diagnosing lag.\")\n  print(\"  - DIFF_ENABLED=\" .. tostring(DIFF_ENABLED) .. \" FULL_SNAPSHOT_SECONDS=\" .. tostring(FULL_SNAPSHOT_SECONDS))\n\n  -- Autodraw (Spectator Tool Autodraw build only). Read our own GUID once --\n  -- every tick's exclusion set starts from it -- and clear any auto zone left in\n  -- the save: broadcasting is always OFF after a load, so one can only be an\n  -- orphan from a save taken mid-broadcast.\n  local okSG, sg = pcall(function() return self.getGUID() end)\n  if okSG and type(sg) == \"string\" then AUTO.selfGuid = sg end\n  AUTO.sweep()\n\n  refreshZoneCacheIfNeeded(true)\n  rebuildRoundRobinLists()\n  local now = os.clock()\n  nextRRRebuildAt = now + RR_REBUILD_SECONDS\n\n  if DEBUG_ENABLED then\n    profResetWindow()\n    PROF.nextLogAt = now + DEBUG_LOG_SECONDS\n  end\n\n  Wait.time(pollLoop, POLL_SECONDS, -1)\nend\n\n-- The TOOL itself being deleted (Spectator Tool Autodraw build only). Without\n-- this the auto zone outlives the object that made it and nothing on the table\n-- knows what it is any more -- a zone nobody can explain and the next spawned\n-- tool would mistake for a hand-drawn one.\nfunction onDestroy()\n  AUTO.destroy()\nend\n\n-- PING CAPTURE (Spectator Tool Autodraw build only, 2026-09-15). The base file\n-- never defined this hook. Everything it does is one bounded table append, and\n-- the whole of it is pcall'd: this runs inside the engine's own event dispatch,\n-- where a raise breaks a good deal more than this tool.\nfunction onPlayerPing(player, position, object)\n  pcall(AUTO.presPing, player, position)\nend\n\n-- PICK-UP ACTIONS (Spectator Tool Autodraw build only, 2026-09-29, design-5\n-- part A). The engine announces a pick-up one frame before onObjectPickUp,\n-- naming the grabbed object and everything that rides with it; AUTO.presAct\n-- keeps the last such list per colour for the riders' fallback (AUTO.presWith).\n-- All of it is pcall'd, as onPlayerPing is: this runs inside the engine's own\n-- dispatch. It RETURNS NOTHING, on purpose and for good: `return false` here\n-- would stop the player's action -- a pick-up, a flip, a delete -- at the table.\nfunction onPlayerAction(player, action, targets)\n  pcall(AUTO.presAct, player, action, targets)\nend\n\n-- HOT SET events (Spectator Tool Autodraw build only, 2026-09-07). The engine\n-- knows a player grabbed something long before a poll tick could infer it from\n-- a position, and it hands over a reference that is alive by definition --\n-- which is the only safe way to get one for an object that has just left a\n-- container.\n--\n-- onObjectEnterContainer is the mirror image: the reference is about to become\n-- unreadable, and reading a dead one raises an uncatchable .NET error that no\n-- pcall can see. It goes to AUTO.markDead, which counts the death like any\n-- other -- and the leave-container event above hands a FRESH reference back\n-- with a fresh stamp on it, which is all it takes for the object to be live\n-- again. See THE ONE DEAD RECORD.\n--\n-- FLIGHT (2026-09-08): leave-container and spawn are the two events that can\n-- be a SMOOTH move -- takeObject{position=..., rotation=...} is how the Arkham\n-- mod draws a chaos token -- so both also hand the object to AUTO.flightNote,\n-- which arms ONE frame callback to ask the engine where it is going. Pick up\n-- and drop are NOT smooth moves and are deliberately left alone.\n--\n-- HOLD EVENTS (2026-09-29): pick-up, drop and leave-container also hand the\n-- event to the presence layer (AUTO.presHold, presence folder), which buffers\n-- it with its own time and position for the next post, so the site knows when\n-- a drag started and ended even when no hand picture fell inside it. The line\n-- is pcall'd: these run inside the engine's own dispatch.\nfunction onObjectPickUp(color, obj)\n  AUTO.markHot(obj)\n  pcall(AUTO.presHold, \"up\", color, obj)\nend\nfunction onObjectDrop(color, obj)\n  AUTO.markHot(obj)\n  pcall(AUTO.presHold, \"down\", color, obj)\nend\n--\n-- THE BIG DECK ROSTER (2026-09-17): both hooks also tell the roster that the\n-- CONTAINER changed. Leave carries the card's number -- its data is still\n-- intact here -- so the roster can pop the top entry and be right again with\n-- no read; enter reads nothing at all, because a dropped card's data is\n-- already gutted and a search-window insert can land anywhere. Both cost one\n-- pcall'd getGUID and one failed hash lookup on a table with no big deck,\n-- which is what these events fire for all day.\nfunction onObjectLeaveContainer(container, obj)\n  AUTO.markHot(obj)\n  AUTO.flightNote(obj)\n  AUTO.rosterLeave(container, obj)\n  pcall(AUTO.presHold, \"out\", nil, obj, container)\nend\nfunction onObjectEnterContainer(container, obj)\n  AUTO.markDead(obj)\n  AUTO.rosterTouch(container)\nend\n\n-- Shuffles/randomizes reorder container contents WITHOUT moving the object, so no\n-- polled property changes -- but this global event DOES fire in an object script\n-- (as onObjectPickUp/Drop and the container events prove). Bumping the epoch flips both\n-- signatures so the deck re-publishes with a fresh top-card preview and peek.\nfunction onObjectRandomize(obj, playerColor)\n  local ok, g = pcall(function() return safeStr(obj.getGUID()) end)\n  local guid = ok and g or \"\"\n  if guid ~= \"\" then SHUFFLE_EPOCH[guid] = (SHUFFLE_EPOCH[guid] or 0) + 1 end\n  -- THE BIG DECK ROSTER (Spectator Tool Autodraw build only, 2026-09-17). The\n  -- epoch bump above moves the deck's signature, so its fragment is rebuilt --\n  -- but out of the roster's OLD order, which is why a shuffle left the site\n  -- showing the card that used to be on top. A shuffle is the one change no\n  -- cheap read can describe, so the record is marked dirty and AUTO.rosterService\n  -- reads it again within AUTO.ROSTER_CAP_WAIT. One pcall'd getGUID and one\n  -- failed hash lookup for every deck that has no roster, which is all of them\n  -- under the 250 cap.\n  AUTO.rosterTouch(obj)\nend\n\n-- Zone create/delete must reach spectators promptly, but zone discovery is a poll\n-- (refreshZoneCacheIfNeeded only re-scans every ZONE_RESCAN_SECONDS). These two\n-- events just flip ZONES_DIRTY so the very next poll tick re-scans.\n--\n-- onObjectSpawn fires for EVERY object spawned in the game, so stay cheap: check\n-- the tag first and only touch the name (a getName() call) for Scripting objects.\nfunction onObjectSpawn(obj)\n  if not obj then return end\n  trackTextSpawn(obj)\n  -- A spawned object is by definition new where it is (Spectator Tool Autodraw\n  -- build only): mark it hot with the fresh reference, so the very next tick\n  -- samples it. Its zone is unknown until a half-scan -- tick A's or tick B's,\n  -- whichever reads it -- fills it in.\n  AUTO.markHot(obj)\n  -- ... and it may be spawning INTO a smooth move (2026-09-08): one frame from\n  -- now the engine will say where, if it is going anywhere.\n  AUTO.flightNote(obj)\n  local okT, tag = pcall(function() return safeStr(obj.tag) end)\n  if not okT or tag ~= \"Scripting\" then return end\n  local okZ, isZone = pcall(function() return isDesiredZone(obj) end)\n  if okZ and isZone then ZONES_DIRTY = true end\nend\n\n-- CRITICAL: onObjectDestroy fires BEFORE the object is actually gone, so a\n-- synchronous rescan here would still find the dying zone via getAllObjects() and\n-- re-cache it. Only set the flag; the next poll tick rescans, by which time the\n-- object is really gone and the diff builder emits {\"zoneRemoved\":\"<guid>\"}.\nfunction onObjectDestroy(obj)\n  if not obj then return end\n  trackTextDestroy(obj)\n  -- Mark, never touch: a deleted object's reference can raise an uncatchable\n  -- .NET error when read (seen for 3DText and blocks), so the round-robin\n  -- steps skip it by GUID. Fires BEFORE the object is gone, so getGUID works.\n  local okG, dg = pcall(function() return obj.getGUID() end)\n  -- ONE RECORD OF DEATH (Spectator Tool Autodraw build only, 2026-09-13). The\n  -- base build marks the round-robin's own set on this line; this build has no\n  -- such set -- the hook counts the death through AUTO instead, and every table\n  -- of references compares against that one count. onObjectEnterContainer goes\n  -- to AUTO.markDead for the same record, so a container entry that fires BOTH\n  -- hooks is two bumps of the counter: harmless, because it only has to go UP.\n  if okG and type(dg) == \"string\" and dg ~= \"\" then AUTO.markDeadGuid(dg) end\n  local okT, tag = pcall(function() return safeStr(obj.tag) end)\n  if not okT or tag ~= \"Scripting\" then return end\n  local okZ, isZone = pcall(function() return isDesiredZone(obj) end)\n  if okZ and isZone then ZONES_DIRTY = true end\nend",
      "LuaScriptState": "",
      "XmlUI": ""
    }
  ]
}
