{
  "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/build-spectator13-xml.py 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, no forced-publish tick, diff truncation fix,\n--    hand-diff pos, REVEAL_HIDDEN 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.5 s) still caps the payloads at two a second. See the build script's\n-- docstring. --plain still builds this at 0.5 s.\nlocal POLL_SECONDS = 0.25\nlocal MIN_POST_INTERVAL = 0.5\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)\nlocal RR_BTN_BUDGET  = 12\nlocal RR_PEEK_BUDGET = 2\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--   dead      -- [guid] = true for every object whose onObjectDestroy hook has\n--                fired since the last rebuild. The steps consult it BEFORE\n--                touching the reference, because reading a deleted one can raise\n--                an uncatchable .NET error (3DText, and blocks seen in game).\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 = {}, dead = {}, visited = {}, contVisited = {} }\n\n-- Autodraw bookkeeping (Spectator Tool Autodraw build only), one table so it\n-- costs one top-level local -- the build is at 193 of Lua's 200-per-scope limit:\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, how far through it we are, the totals and the percentage\n--               on the Broadcast button. Declared inactive here so\n--               AUTO.setup.active can be read on the very first tick.\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               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  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  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  for k, _ in pairs(PROF) do\n    -- keep tables as tables\n  end\n\n  PROF.polls = 0\n  PROF.publishesAttempted = 0\n  PROF.publishesSent = 0\n  PROF.publishesAck200 = 0\n  PROF.publishesErr = 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_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        \" | 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        \" | 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  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  local ok, data = pcall(function() return obj.getData() end)\n  if ok and data then return data end\n  return nil\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-- Injected by tts/build-spectator13-xml.py. The shipping Spectator 13 has none\n-- of this. Nothing here goes near lightObjSig: both calls run only from the item\n-- builders, which are on the round-robin sweep and the full, never on the\n-- per-object-per-tick signature path.\nlocal function tryGetXmlTree(obj)\n  if not obj then return nil end\n  local ok, t = pcall(function() return obj.UI.getXmlTable() end)\n  if not ok or type(t) ~= \"table\" or #t == 0 then return nil end\n  return t\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.\nlocal xmlPrev, xmlRev = {}, {}\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\" or #s == 0 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  if s ~= xmlPrev[g] then\n    xmlPrev[g] = s\n    xmlRev[g] = (xmlRev[g] or 0) + 1\n  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-- see warn_top_level_locals; the default build sits at 193 of Lua's 200).\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-- Injected by tts/build-spectator13-xml.py at the SAME anchor as the XML\n-- helpers above, which matters: stampNameScale (~line 787 of the shipping Lua)\n-- calls textSigFor, and Lua locals are lexically scoped, so textSigFor has to be\n-- defined above it. The other half of this feature -- zoneObjectsPlusTexts -- is\n-- injected later in the file, 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 \"\")\nend\n\n-- Container tags whose live quantity (contained-object count) must ride the\n-- signatures. Shared by lightObjSig, cheapZonesSignature, and the fragment\n-- 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    profSlowPush(\"buttons\", \"getButtons guid=\" .. safeStr(obj.getGUID()) .. \" name=\" .. safeName(obj), dt)\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-- 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.\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\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  -- 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  local ok, list = pcall(function() return obj.getObjects() end)\n  if ok and type(list) == \"table\" then return list end\n  return nil\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\n-- (deckPeekHybrid / deckPeekFromGetObjects / deckPeekFromDeckIDs unchanged from your version)\n-- To keep this response within limits, they are included verbatim below.\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  if 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  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. All five `z.getObjects()` reads in this file are\n-- rewritten by the build script to come through here, so a text inside a zone is\n-- an ordinary member of it everywhere: snapshot, diff, dead-GUID sweep -- all\n-- but the round-robin, which considerObj deliberately skips for texts (see\n-- there). Nothing else downstream knows this feature exists.\n--\n-- `z.getObjects()` is left to throw exactly as it did before -- every one of the\n-- five call sites already wraps this in a pcall and treats a throw as \"the zone\n-- reference is dying\", 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          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  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  local hc = 1\n  local okHC, n = pcall(function() return p.getHandCount() end)\n  if okHC and type(n) == \"number\" and n > 1 then hc = math.floor(n) end\n  for handIdx = 1, hc do\n    local handObjs = (hc > 1 and p.getHandObjects(handIdx) or p.getHandObjects()) or {}\n    fn(handObjs, handIdx)\n  end\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 mark collected since the last\n  -- one is moot the moment it finishes -- which is also what bounds the memory:\n  -- `dead` can never hold more than one rebuild interval of deletions.\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 = {}; RR_META.dead = {}\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 RR_META.dead[g] 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-- Injected by tts/build-spectator13-xml.py --autodraw. See the module docstring\n-- for WHY. In short: 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 (artifacts/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  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--   * every object in every player's hand -- those are FACE UP to their owner,\n--     so a table-wide zone would put every player's hand on the public site.\n-- One walk of the seated players per tick; the hand lists are the same ones\n-- rebuildRoundRobinLists already reads.\nAUTO.refreshExcl = function()\n  local excl = {}\n  if AUTO.selfGuid ~= \"\" then excl[AUTO.selfGuid] = true end\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  -- 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  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 build is at 194 of Lua's 200-per-scope limit -- 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    profSlowPush(\"buttons\", \"getInputs guid=\" .. safeStr(obj.getGUID()) .. \" name=\" .. safeName(obj), dt)\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    profSlowPush(\"buttons\", \"getDecals guid=\" .. safeStr(obj.getGUID()) .. \" name=\" .. safeName(obj), dt)\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\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.xml = tryGetXmlTree(obj)\n  if item.xml then item.uiAssets = tryGetUiAssets(obj) end\n  item.kind = tryGetKind(obj)\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  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    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.xml = tryGetXmlTree(obj)\n  if it.xml then it.uiAssets = tryGetUiAssets(obj) end\n  it.kind = tryGetKind(obj)\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  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\nlocal function snapshotHands()\n  local players = {}\n  local plist = Player.getPlayers()\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 = itemForHandObj(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\nlocal function cheapZonesSignature()\n  local zones = refreshZoneCacheIfNeeded(false)\n  local zparts = {}\n\n  for _, z in ipairs(zones) do\n    -- A dead zone contributes nothing to the gate. That is deliberate: dropping\n    -- its part CHANGES the signature, which is exactly the event that triggers\n    -- the publish whose diff carries {\"zoneRemoved\":...}.\n    if zoneAlive(z) then\n      local zg = safeStr(z.getGUID())\n      local ok, objs = pcall(function() return zoneObjectsPlusTexts(z) end)\n      if not ok or type(objs) ~= \"table\" then\n        zparts[#zparts + 1] = \"Z:\" .. zg .. \":ERR\"\n      else\n        local oparts = {}\n        for _, o in ipairs(objs) do\n          local g = o.getGUID()\n          -- Excluded objects cost NOTHING extra here: the GUID this loop\n          -- already read is the key, so the per-tick hot path pays one\n          -- hash lookup and no second TTS call. Skipping the body leaves\n          -- the object out of oparts, so the gate ignores it exactly as\n          -- the four AUTO.filtered consumers do.\n          if not AUTO.excl[g] then\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.y)\n            -- Fold button labels (pure BTN_CACHE lookup) into the gate so a stationary\n            -- object's label change still publishes on a still table.\n            local bs = buttonsSig(g)\n            if bs ~= \"\" then s = s .. \":\" .. bs end\n            -- Fold RR-cached name/scale sigs (pure lookup) so a rename or scale edit\n            -- on a still object still publishes on a still table.\n            local rms = rrMetaSig(g)\n            if rms ~= \"\" then s = s .. \":\" .. rms end\n            -- Live Counter value, guarded by tag so we only pcall getValue() per\n            -- Counter (not per object) each 0.5s tick.\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            -- Live container quantity, tag-guarded so only containers pay a\n            -- getQuantity() per tick. Without this a deck drawn from without moving\n            -- changes no sig and (with rare fulls) never re-publishes its count.\n            if isContainerTag(tostring(o.tag or \"\")) then\n              s = s .. \"Q\" .. tostring(tryGetQuantity(o))\n            end\n            -- Shuffle epoch (pure lookup): a randomize reorders contents in place, so\n            -- only this bumped counter reveals it on the gate.\n            if SHUFFLE_EPOCH[g] then s = s .. \"E\" .. SHUFFLE_EPOCH[g] end\n            oparts[#oparts + 1] = s\n          end\n        end\n        table.sort(oparts)\n        zparts[#zparts + 1] = \"Z:\" .. zg .. \":\" .. tostring(#oparts) .. \":\" .. table.concat(oparts, \";\")\n      end\n    end\n  end\n\n  table.sort(zparts)\n  return table.concat(zparts, \"||\")\nend\n\nlocal function buildCheapSignature()\n  local parts = {}\n  for _, p in ipairs(Player.getPlayers()) do\n    parts[#parts + 1] = \"H:\" .. p.color .. \":\" .. cheapHandSignatureForPlayer(p)\n  end\n  table.sort(parts)\n\n  parts[#parts + 1] = cheapZonesSignature()\n\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  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    if pe then pe.nextAt = 0 end\n  end\n\n  local item = itemForObject(obj, staleOk)\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\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(Player.getPlayers()) 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    newHandState[p.color] = { hand = cur, order = curOrder }\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    .. ',\"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  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      local adds, updates, removes = {}, {}, {}\n\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          local og = safeStr(o.getGUID())\n          if og ~= \"\" then\n            local sig = lightObjSig(o, nil)\n            local oldSig = prev.objs[og]\n            if not oldSig then\n              -- new object => add\n              if totalAdds < MAX_DIFF_OBJECT_ADDS then\n                adds[#adds+1] = 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] = 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      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\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  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  local players = Player.getPlayers()\n  local seenPlayers = {}\n\n  for _, p in ipairs(players) do\n    local color = p.color\n    seenPlayers[color] = true\n\n    local prev = LAST_HAND_STATE[color]\n    if not prev then\n      prev = { hand = {}, order = {} }\n      LAST_HAND_STATE[color] = prev\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 = itemForHandObj(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 = itemForHandObj(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    if (#adds > 0) or (#updates > 0) or (#removes > 0) then\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    if 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  return '{\"type\":\"diff\",\"code\":' .. jencTimed(roomCode)\n    .. ',\"ts\":' .. tostring(os.time())\n    .. ',\"seq\":' .. tostring(DIFF_SEQ)\n    .. UI_ASSETS.payloadField(false)\n    .. ',\"zoneDiffs\":[' .. jconcatTimed(zoneJsons, \",\") .. ']'\n    .. ',\"handDiffs\":' .. handJson .. '}', \"diff\"\nend\n\nlocal function buildPayload(forceFull)\n  DIFF_SEQ = (DIFF_SEQ or 0) + 1\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 = '{\"code\":\"XAML\"}'\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    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\n    RR_OBJECTS = {}\n    RR_CONTAINERS = {}\n    rrObjIdx = 1\n    rrContIdx = 1\n    nextRRRebuildAt = 0\n    RR_META.visited = {}; RR_META.contVisited = {}; RR_META.dead = {}\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  if inFlight then return 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  if not force and lastSeenSig == lastAckSig 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  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  inFlight = true\n  lastPostAt = now\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\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      lastAckSig = lastSeenSig\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-- Injected by tts/build-spectator13-xml.py. See the module docstring for 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 per\n-- poll, with the publish held back until it is done.\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 poll 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  -- 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.\n  AUTO.setup = { active = true, list = list, i = 1, total = #list,\n                 prepared = 0, polls = 0, pct = 0, started = os.clock() }\n  print(\"[Spectator] Setting up: \" .. tostring(#list) .. \" object(s) to prepare.\")\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  local now = os.clock()\n  ensureMetaSeeded(o, guid, now)\n  extractButtonsCached(o, true)\n  encodedZoneItemFor(o, lightObjSig(o, nil), true)\nend\n\n-- The end of the warm-up, however it ended. It runs exactly once per setup:\n-- setupStep 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  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.\")\nend\n\n-- One poll's slice of the work list.\nAUTO.setupStep = function()\n  local s = AUTO.setup\n  if not s.active then return end\n  -- How many milliseconds of os.clock time ONE poll may spend preparing objects.\n  -- LOWERING it smooths the game further at the cost of a longer setup; raising\n  -- it finishes sooner at the cost of a chunkier frame. At least one object is\n  -- always processed however long that object takes, so setup can never stall.\n  local WORK_BUDGET_MS = 40\n  -- Safety cap, so a table that somehow never finishes still ends up\n  -- broadcasting. 400 polls is 100 s at POLL_SECONDS = 0.25; the seconds test is\n  -- the one that still holds if the poll rate is changed again.\n  local MAX_POLLS = 400\n  local MAX_SECONDS = 100\n\n  local t0 = os.clock()\n  local list = s.list or {}\n  local n = #list\n  s.polls = (s.polls or 0) + 1\n\n  repeat\n    local it = list[s.i]\n    if it == nil then break end\n    s.i = s.i + 1\n    -- NEVER call a method on an object whose GUID onObjectDestroy has marked:\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, so nothing\n    -- has to touch the reference to reach it.\n    if not RR_META.dead[it.guid] then\n      -- One bad object must not abort the whole warm-up.\n      if pcall(AUTO.setupOne, it.obj, it.guid) then\n        s.prepared = (s.prepared or 0) + 1\n      end\n    end\n  until (os.clock() - t0) * 1000 >= WORK_BUDGET_MS\n\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\n  local capped = (s.polls >= MAX_POLLS)\n                 or ((os.clock() - (s.started or 0)) >= MAX_SECONDS)\n  if done >= n or capped then AUTO.setupFinish(done < n) end\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 prepares a slice of the objects and returns.\n  -- Publishing is suppressed as well (see publishIfNeeded), because a payload\n  -- built now would encode every fragment cold in one frame -- the 1.5 s freeze\n  -- 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.\n  end\n\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  -- rebuild RR lists occasionally\n  if now >= (nextRRRebuildAt or 0) then\n    rebuildRoundRobinLists()\n    nextRRRebuildAt = now + RR_REBUILD_SECONDS\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\n  local s0 = os.clock()\n  local sig = buildCheapSignature()\n  local sdt = os.clock() - s0\n  if DEBUG_ENABLED then profAdd(\"t_sig\", sdt) end\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  end\n\n  local pdt = os.clock() - p0\n  if DEBUG_ENABLED then\n    profAdd(\"t_poll_total\", pdt)\n    profSlowPush(\"poll\", \"pollLoop total\", 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 })\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  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\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\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  setButtonLabels()\n  print(\"[Spectator] Loaded.\")\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\nfunction onObjectEnterHand(color, object) pollLoop() end\nfunction onObjectLeaveHand(color, object) pollLoop() end\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 onObjectEnter/LeaveHand above 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\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  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  if okG and type(dg) == \"string\" and dg ~= \"\" then RR_META.dead[dg] = true 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": ""
    }
  ]
}
