Skip to content

Telemetry v2 — implementation plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Stop losing telemetry. Every event goes through one on-disk client queue to a single deduping POST /events route. Add a crash/exit signal, lifecycle and funnel events, a mid-run timeline, real GPU/CPU frame timing, an in-game “Share diagnostics” toggle, and iOS-only MetricKit and Sentry.

Architecture:

  • Pure core, thin Node. net/event_queue_core.gd is pure RefCounted and holds the envelope, cap, batch, ack and backoff logic. net/event_queue.gd is a persistent Node that wraps it and owns the HTTPRequest.
  • All reporters push through the queue. That covers perf, hitch, run, encounter, feedback, lifecycle, MetricKit and timeline.
  • Worker fan-out. For each event the Worker inserts into a new events table with INSERT OR IGNORE, and also inserts into the matching legacy table under a WHERE NOT EXISTS (events.id) guard. Both happen in one D1 batch() transaction, so a resend is a no-op everywhere.
  • The sim change is small. Two plain counters (xp_by_type, boss_log) sit outside the checksum. Everything else is render-side.

Tech Stack: Godot 4.7.1 typed GDScript, GUT 9.7, Cloudflare Worker + D1 (telemetry/), Node 22 node --test (with node:sqlite for a real-SQL D1 shim), Objective-C++ (MetricKit), sentry-godot 2.2.0 (GDExtension).

Spec: docs/superpowers/specs/2026-09-29-telemetry-v2-design.md. Background: docs/research/2026-09-28-telemetry-gaps-and-options.md.

Project rules

  • /sim stays pure and deterministic. Every sim/*.gd file extends RefCounted, with no Node/Engine/Time/OS/HTTP. The new sim fields are plain counters outside state_checksum()/snapshot_string(). They make no RNG draws and cause no ordering change.
  • The seed-1234 baseline in tests/test_determinism_survival.gd must not move. Never re-pin it in this plan. If it moves, the change is wrong.
  • bash scripts/gate.sh is the merge gate and must be green after every task. It runs git checkout -- project.godot (root), so commit any intentional root project.godot edit BEFORE gating. This plan does not edit the root project.godot.
  • platform/tvos gameplay dirs are symlinks. Never cp/rsync into them. (Task 9 turns platform/tvos/addons from a symlink into a real dir that holds a gut symlink plus sentry/. It is not a gameplay dir; see Task 9.)
  • Use /usr/bin/grep, never bare grep, in any interactive check. The shell’s grep is a shim that honours .gitignore. Scripts that already use grep are fine.

How to run and commit

  • Worker tests: node --test telemetry/test/*.test.mjs. Script tests: node --test scripts/test/*.test.mjs (the directory form fails on Node 22).
  • GUT single script: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=<script_name_without_.gd> -gexit. After adding any new class_name file, run godot --headless --path . --import first, or GUT silently drops the new script.
  • Typed GDScript: a := from a Variant is a compile error. Give Variant-derived vars an explicit type. An un-asserted push_error fails a GUT test.
  • One commit per task, on the task’s branch. Write the commit message to a file and use git commit -F <file>, never git commit -m "$(cat <<EOF ...)" (macOS bash 3.2 apostrophe bug).

Deploys and privacy

  • No deploys, no App Store Connect edits and no Sentry project creation in any task. Those are in “Chris-gated”, at the end, and need Chris’s same-day OK.
  • Secret values never go in the repo or transcript. The Sentry DSN lives in a gitignored platform/tvos/sentry_dsn.json that Chris creates. The Cloudflare account id 06f5622d4192dcefefbe0da75d766bfc is not a secret.
  • Telemetry never affects play. Every network or disk failure is silent, as today.
  • The queue is disabled (push is a no-op) in the editor, headless, capture, replay and showcase, and whenever MetaState.share_diagnostics is false.
  • Envelope field names are exact: id, t, ts, install, launch, session, build, channel, dev, auto, d.
  • Event type names are exact: sample, hitch, run, encounter, feedback, launch, run_start, run_end, menu, tutorial_step, shop_buy, background, foreground, prev_exit, metrickit.
  • run_end.reason is one of exactly: death, story_complete, tutorial_cap, quit_menu, dev_restart.
  • runs rows stay “banked runs only”. A quit to menu or a background kill never writes a runs row; it writes a run_end event.
  • The old Worker routes (/sample, /hitch, /gameplay, /encounter, /feedback) stay working for old clients.

Deviations from the spec (decided here, overrule freely)

Section titled “Deviations from the spec (decided here, overrule freely)”
  1. ≤ 20 events per POST /events, not 50. Each event costs 2 D1 statements (legacy insert + events insert), and every statement in a batch() counts as a query. The free Workers plan allows 50 queries per invocation [Certain per D1 limits docs, 2026-09-28 research]. 20 events × 2 = 40, plus at most 5 feedback rate-limit SELECTs, gives 45.
  2. Ack rule is ok + dup + bad == sent, not ok + dup. A permanently malformed event (bad) would otherwise wedge the head of the queue forever. A 400 or 413 also drops the batch. A 404, 429, 5xx or network failure keeps it and backs off, so a v2 client that ships before the Worker deploy loses nothing.
  3. Queue file lines are <id>\t<type>\t<json>, not bare JSONL. Ack and cap then never re-parse JSON. Godot’s JSON.stringify escapes tabs, so a raw tab never appears inside the JSON. File: telemetry_queue.txt.
  4. MetricKit payload files go to the Caches ROOT as mk-<kind>-<ms>-<rand>.json, not <Caches>/metrickit/. tvOS cannot create subdirectories in the app sandbox (see main.gd _save_replay_if_recording). Keeping iOS and tvOS on one pattern avoids a second code path.
  5. channel_probe is sent once per launch in launch.d, not on every event. Legacy channel_probe columns are NULL for v2 clients. Join on launch_id.
  6. Sentry is vendored into platform/tvos/addons/sentry/, not root addons/.
    • platform/tvos/addons is already a whole-dir symlink to root addons/, so “symlink it in like other shared dirs” would put a GDExtension into the root project.
    • The root project serves the Web export (extensions_support=false) and the macOS gate/GUT runs.
    • Keeping it in the iOS/tvOS project means the web preset (gitignored, uncommittable) needs no exclude_filter, and the gate never loads a native library.
    • It is not committed. scripts/release/fetch-sentry.sh fills the gitignored dir from the sha256-pinned 2.2.0 release zip (iOS + macOS libs, 44.8 MB). That keeps binaries out of git for a dormant feature. A clone without it just has no Sentry.
  7. The events table gains rts (server receive ms) next to the client ts. Legacy rows written via /events use the client ts when plausible (within 30 days back / 1 day ahead), else server time. That way an offline-queued run lands at the time it was played.
  8. runs.kills_by_type truncation raised 400 → 2000. It has the same silent-truncation bug as build_picks, and it produces invalid JSON when cut.
  9. The MetricKit sink is appended to the export’s own dummy.cpp (renamed dummy.mm), not shipped as a replacement file. That keeps Godot’s generated plugin stubs. The payload goes as raw JSON text (d.raw), not parsed and not truncated mid-JSON: Godot’s parser makes every number a float and would corrupt 64-bit stack addresses. Over 200 KB, a size stub is sent instead.
  10. The pause-menu Diagnostics button shares the “Send feedback” row on the glass look. A full-width row breaks the existing 660 px fit test with dev rows.
  11. Hitch/sample proc_ms/render_ms keep their names and values. Only labels change (dashboard, schema comment). Renaming columns would break every old row and query.
File Responsibility Task
telemetry/schema.sql + events table, new columns in CREATEs, 19 ALTERs in the migration comment block 1
telemetry/migrations/2026-09-29-telemetry-v2.sql The live-DB migration, one statement per line 1
telemetry/src/worker.js Shared legacy row builders, POST /events, dashboard labels 1, 6
telemetry/test/d1shim.mjs D1-compatible shim over node:sqlite loaded from schema.sql 1
telemetry/test/events.test.mjs /events + legacy-on-real-schema tests 1
net/event_queue_store.gd EventQueueStore: in-memory line store (base class) 2
net/event_queue_file_store.gd EventQueueFileStore: the on-disk queue file 2
net/event_queue_core.gd EventQueueCore: envelope, cap, batch, ack, backoff (pure) 2
net/event_queue.gd EventQueue Node: HTTP, flush timing, session/auto/enable 2
net/telemetry.gd, net/gameplay_telemetry.gd, net/dash_encounter_telemetry.gd Push through the queue 3
net/exit_marker.gd ExitMarker: classify the previous exit (pure) 4
net/lifecycle_telemetry.gd LifecycleTelemetry Node: launch/prev_exit/background/foreground/menu/tutorial_step + marker 4
net/local_json.gd + remove(name) 4
ui/meta_shop_panel.gd + purchased(id, cost, gold_after) signal 4
sim/sim.gd + five boss files xp_by_type, boss_log, note_boss_spawn 5
net/run_timeline.gd RunTimeline: level-ups offered/picked + to_dict(sim) 5
net/hitch_sampler.gd HitchSampler: must-send + worst-2-per-30 s + 200 cap 6
net/hitch_tracker.gd − session cap (moved to HitchSampler) 6
sim/meta_state.gd, meta/meta_store.gd, net/remote_config.gd, ui/pause_menu.gd Share-diagnostics toggle 7
marketing/appstore/compliance.md New privacy answers 7
platform/tvos/patches/ios/metrickit-sink.mm MetricKit subscriber, appended to the export’s own plugin stub (dummy.cpp → dummy.mm) 8
scripts/release/apply-ios-metrickit.sh Re-apply the iOS patch into build-ios/ (idempotent) 8
net/metrickit_pickup.gd MetricKitPickup: payload files → metrickit events 8
platform/tvos/addons/ (real dir), platform/tvos/addons/sentry/, net/sentry_boot.gd Dormant iOS Sentry 9
scripts/release/fetch-sentry.sh, tools/sentry_probe/probe.gd Pinned addon fetch; one-shot API probe 9
scripts/test/apply-ios-metrickit.test.mjs Node test of the iOS re-apply script against a fixture shell 8
.gitignore, platform/tvos/project.godot, platform/tvos/export_presets.cfg, scripts/release/build-ios.sh, scripts/dev/install-dev.sh Sentry ignore/auto_init/tvOS exclude; build hooks 8, 9
docs/architecture/dev-tools.md Telemetry v2 section, dSYM result 10, 11
main.gd Wiring 3, 4, 5, 7, 9

Task 1: Worker — events table, POST /events, shared legacy builders, raised truncation

Section titled “Task 1: Worker — events table, POST /events, shared legacy builders, raised truncation”

Files:

  • Modify: telemetry/schema.sql
  • Create: telemetry/migrations/2026-09-29-telemetry-v2.sql
  • Modify: telemetry/src/worker.js (helpers after line 17 const str = ...; the five POST route bodies at lines ~70-203)
  • Create: telemetry/test/d1shim.mjs
  • Create: telemetry/test/events.test.mjs

Interfaces:

  • Produces, for all later client tasks:

    • POST /events, body {"v":1,"events":[<envelope>...]} (text/plain). The body is at most EVENTS_MAX_BYTES = 524288 bytes and holds at most EVENTS_MAX = 20 events.
    • Responses: 200 {"ok":n,"dup":m,"bad":k}, 400 {"error":...} (bad json / no events), 413 (too large / too many), 500 (db).
    • Legacy fan-out types:
      • sample → samples
      • hitch → hitches
      • run → runs
      • encounter → encounters
      • feedback → feedback
    • Every other valid type is stored in events.data only.
    • Legacy payload keys are read from d exactly as the old routes read the top-level body. For session, build, channel, dev and is_auto/auto, the envelope overrides d.
  • Produces (exported for tests): EVENTS_MAX, EVENTS_MAX_BYTES, sampleRow, hitchRow, runRow, encounterRow, feedbackRow.

  • Step 1: Add the events table, the new columns and the migration notes to telemetry/schema.sql

In each CREATE TABLE, add the new columns just before the closing );. Keep the comma on the previous last column.

  • samples: install_id TEXT, launch_id TEXT, event_id TEXT, auto INTEGER, gpu_ms REAL, render_cpu_ms REAL
  • runs: install_id TEXT, launch_id TEXT, event_id TEXT, timeline_json TEXT
  • encounters, feedback, hitches: install_id TEXT, launch_id TEXT, event_id TEXT

For example, samples ends:

channel TEXT, -- build channel: dev | testflight | appstore | web; NULL = older client
channel_probe TEXT, -- diagnostic probe backing the channel guess; NULL = older client
install_id TEXT, -- telemetry v2: random install id (MetaState.install_id); NULL = pre-v2 route
launch_id TEXT, -- telemetry v2: per-process launch id
event_id TEXT, -- telemetry v2: events.id this row came from
auto INTEGER, -- telemetry v2: 1 = autopilot/attract run at sample time; NULL = pre-v2
gpu_ms REAL, -- telemetry v2: viewport GPU render time; NULL = unsupported/pre-v2
render_cpu_ms REAL -- telemetry v2: viewport CPU render time + frame setup; NULL = unsupported/pre-v2
);

In the existing 2026-09-28 build-channels comment, replace the line -- all five tables. NULL for clients built before this. DEFERRED — not yet applied to the live DB. with:

-- all five tables. NULL for clients built before this. APPLIED to the live DB 2026-09-28 (Worker
-- version 60fa39b6); the DEFERRED note that used to be here was stale.

Append at the very end of the file:

-- Telemetry v2 (2026-09-29, spec docs/superpowers/specs/2026-09-29-telemetry-v2-design.md): one
-- deduping event log. Every client event lands here once (id = client-generated 32-hex, the dedupe
-- key); sample/hitch/run/encounter/feedback ALSO fan out into their legacy table in the same D1
-- batch, guarded by NOT EXISTS on this id, so a resend is a no-op everywhere. `data` is NULL for
-- those fanned-out types (the row lives in its own table) and the JSON payload for the rest.
-- ts = client push time (ms), rts = server receive time (ms).
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
ts INTEGER NOT NULL,
rts INTEGER NOT NULL,
type TEXT NOT NULL,
install TEXT,
launch TEXT,
session TEXT,
build INTEGER,
channel TEXT,
dev TEXT,
auto INTEGER,
data TEXT
);
CREATE INDEX IF NOT EXISTS idx_events_type_ts ON events(type, ts);
CREATE INDEX IF NOT EXISTS idx_events_install ON events(install);
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session);
-- Migration 2026-09-29 telemetry v2 (NOT YET APPLIED — see telemetry/migrations/2026-09-29-telemetry-v2.sql
-- and the plan's Chris-gated section; apply BEFORE deploying the Worker that names these columns).
-- ALTER TABLE samples ADD COLUMN install_id TEXT;
-- ALTER TABLE samples ADD COLUMN launch_id TEXT;
-- ALTER TABLE samples ADD COLUMN event_id TEXT;
-- ALTER TABLE samples ADD COLUMN auto INTEGER;
-- ALTER TABLE samples ADD COLUMN gpu_ms REAL;
-- ALTER TABLE samples ADD COLUMN render_cpu_ms REAL;
-- ALTER TABLE runs ADD COLUMN install_id TEXT;
-- ALTER TABLE runs ADD COLUMN launch_id TEXT;
-- ALTER TABLE runs ADD COLUMN event_id TEXT;
-- ALTER TABLE runs ADD COLUMN timeline_json TEXT;
-- ALTER TABLE encounters ADD COLUMN install_id TEXT;
-- ALTER TABLE encounters ADD COLUMN launch_id TEXT;
-- ALTER TABLE encounters ADD COLUMN event_id TEXT;
-- ALTER TABLE feedback ADD COLUMN install_id TEXT;
-- ALTER TABLE feedback ADD COLUMN launch_id TEXT;
-- ALTER TABLE feedback ADD COLUMN event_id TEXT;
-- ALTER TABLE hitches ADD COLUMN install_id TEXT;
-- ALTER TABLE hitches ADD COLUMN launch_id TEXT;
-- ALTER TABLE hitches ADD COLUMN event_id TEXT;
  • Step 2: Create telemetry/migrations/2026-09-29-telemetry-v2.sql

Put one statement per line with no comments. The Chris-gated deploy runs each line through --command, because a --file ALTER once silently didn’t apply (bh-telemetry skill, 2026-09-27).

CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, ts INTEGER NOT NULL, rts INTEGER NOT NULL, type TEXT NOT NULL, install TEXT, launch TEXT, session TEXT, build INTEGER, channel TEXT, dev TEXT, auto INTEGER, data TEXT);
CREATE INDEX IF NOT EXISTS idx_events_type_ts ON events(type, ts);
CREATE INDEX IF NOT EXISTS idx_events_install ON events(install);
CREATE INDEX IF NOT EXISTS idx_events_session ON events(session);
ALTER TABLE samples ADD COLUMN install_id TEXT;
ALTER TABLE samples ADD COLUMN launch_id TEXT;
ALTER TABLE samples ADD COLUMN event_id TEXT;
ALTER TABLE samples ADD COLUMN auto INTEGER;
ALTER TABLE samples ADD COLUMN gpu_ms REAL;
ALTER TABLE samples ADD COLUMN render_cpu_ms REAL;
ALTER TABLE runs ADD COLUMN install_id TEXT;
ALTER TABLE runs ADD COLUMN launch_id TEXT;
ALTER TABLE runs ADD COLUMN event_id TEXT;
ALTER TABLE runs ADD COLUMN timeline_json TEXT;
ALTER TABLE encounters ADD COLUMN install_id TEXT;
ALTER TABLE encounters ADD COLUMN launch_id TEXT;
ALTER TABLE encounters ADD COLUMN event_id TEXT;
ALTER TABLE feedback ADD COLUMN install_id TEXT;
ALTER TABLE feedback ADD COLUMN launch_id TEXT;
ALTER TABLE feedback ADD COLUMN event_id TEXT;
ALTER TABLE hitches ADD COLUMN install_id TEXT;
ALTER TABLE hitches ADD COLUMN launch_id TEXT;
ALTER TABLE hitches ADD COLUMN event_id TEXT;
  • Step 3: Write the D1 shim telemetry/test/d1shim.mjs
// A D1-compatible database over node:sqlite (Node 22), built from telemetry/schema.sql, so Worker
// tests run the Worker's REAL SQL against the REAL schema (a typo'd column fails here, not in prod).
// Not a *.test.mjs file, so `node --test telemetry/test/*.test.mjs` does not run it on its own.
import { DatabaseSync } from "node:sqlite";
import { readFileSync } from "node:fs";
export function d1FromSchema() {
const db = new DatabaseSync(":memory:");
const sql = readFileSync(new URL("../schema.sql", import.meta.url), "utf8");
db.exec(sql);
// The live DB got many columns via the commented ALTER notes; a fresh DB needs them too. ALTERs
// for columns a CREATE already has fail with "duplicate column" -- expected, skipped.
for (const m of sql.matchAll(/^--\s+(ALTER TABLE .+;)\s*$/gm)) {
try { db.exec(m[1]); } catch (e) { if (!/duplicate column/i.test(String(e.message))) throw e; }
}
const stmt = (sql, args = []) => ({
_sql: sql, _args: args,
bind: (...a) => stmt(sql, a),
run: async () => ({ success: true, meta: { changes: Number(db.prepare(sql).run(...args).changes) } }),
first: async () => db.prepare(sql).get(...args) ?? null,
all: async () => ({ results: db.prepare(sql).all(...args) }),
});
return {
raw: db,
prepare: (sql) => stmt(sql),
// D1 batch(): one transaction, one result per statement, rolled back as a whole on error.
batch: async (stmts) => {
db.exec("BEGIN");
try {
const out = stmts.map((s) => ({ success: true, meta: { changes: Number(db.prepare(s._sql).run(...s._args).changes) } }));
db.exec("COMMIT");
return out;
} catch (e) { db.exec("ROLLBACK"); throw e; }
},
};
}
  • Step 4: Write the failing tests telemetry/test/events.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import worker, { EVENTS_MAX, EVENTS_MAX_BYTES } from "../src/worker.js";
import { d1FromSchema } from "./d1shim.mjs";
let n = 0;
const hex = () => (++n).toString(16).padStart(32, "0");
const env0 = { install: "inst1", launch: "lnch1", session: "sess1", build: 210, channel: "dev", dev: "iOS/iPhone15,2", auto: 0 };
const ev = (t, d = {}, extra = {}) => ({ id: hex(), t, ts: Date.now(), ...env0, d, ...extra });
const post = (DB, body, path = "/events") =>
worker.fetch(new Request("https://x" + path, { method: "POST", body: typeof body === "string" ? body : JSON.stringify(body) }), { DB });
const rows = (DB, sql, ...a) => DB.raw.prepare(sql).all(...a);
test("a sample event lands in events (data NULL) and in samples with the v2 columns", async () => {
const DB = d1FromSchema();
const e = ev("sample", { fps: 58.5, frame_ms: 17.1, gpu_ms: 6.2, render_cpu_ms: 3.1, quality: 1, config_id: "default" }, { auto: 1 });
const res = await post(DB, { v: 1, events: [e] });
assert.equal(res.status, 200);
assert.deepEqual(await res.json(), { ok: 1, dup: 0, bad: 0 });
const [row] = rows(DB, "SELECT * FROM events");
assert.equal(row.id, e.id); assert.equal(row.type, "sample"); assert.equal(row.data, null);
assert.equal(row.install, "inst1"); assert.equal(row.auto, 1);
const [s] = rows(DB, "SELECT * FROM samples");
assert.equal(s.session, "sess1"); assert.equal(s.build, 210); assert.equal(s.fps, 58.5);
assert.equal(s.install_id, "inst1"); assert.equal(s.launch_id, "lnch1"); assert.equal(s.event_id, e.id);
assert.equal(s.auto, 1); assert.equal(s.gpu_ms, 6.2); assert.equal(s.render_cpu_ms, 3.1);
assert.equal(s.dev, "iOS/iPhone15,2"); assert.equal(s.channel, "dev"); assert.equal(s.channel_probe, null);
});
test("a resend is a dup and writes no second legacy row", async () => {
const DB = d1FromSchema();
const e = ev("run", { mode: "survival", level: 9 });
await post(DB, { v: 1, events: [e] });
const res = await post(DB, { v: 1, events: [e] });
assert.deepEqual(await res.json(), { ok: 0, dup: 1, bad: 0 });
assert.equal(rows(DB, "SELECT COUNT(*) n FROM runs")[0].n, 1);
assert.equal(rows(DB, "SELECT COUNT(*) n FROM events")[0].n, 1);
});
test("the same id twice in ONE batch counts once", async () => {
const DB = d1FromSchema();
const e = ev("encounter", { enemy_type: "charger", outcome: "clear" });
const res = await post(DB, { v: 1, events: [e, e] });
assert.deepEqual(await res.json(), { ok: 1, dup: 1, bad: 0 });
assert.equal(rows(DB, "SELECT COUNT(*) n FROM encounters")[0].n, 1);
});
test("unknown types live in events.data only", async () => {
const DB = d1FromSchema();
const e = ev("launch", { os: "iOS", first_launch: true });
await post(DB, { v: 1, events: [e] });
const [row] = rows(DB, "SELECT * FROM events");
assert.deepEqual(JSON.parse(row.data), { os: "iOS", first_launch: true });
for (const t of ["samples", "runs", "hitches", "encounters", "feedback"]) {
assert.equal(rows(DB, `SELECT COUNT(*) n FROM ${t}`)[0].n, 0, t);
}
});
test("malformed events are counted bad and skipped; good ones in the same batch still land", async () => {
const DB = d1FromSchema();
const good = ev("menu");
const res = await post(DB, { v: 1, events: [good, { id: "nothex", t: "menu" }, { id: hex(), t: "Bad-Type" }, null, ev("feedback", { note: "x" })] });
assert.deepEqual(await res.json(), { ok: 1, dup: 0, bad: 4 });
});
test("limits: too many events and too large a body are 413, bad json is 400", async () => {
const DB = d1FromSchema();
const many = Array.from({ length: EVENTS_MAX + 1 }, () => ev("menu"));
assert.equal((await post(DB, { v: 1, events: many })).status, 413);
assert.equal((await post(DB, "x".repeat(EVENTS_MAX_BYTES + 1))).status, 413);
assert.equal((await post(DB, "{not json")).status, 400);
assert.equal((await post(DB, { v: 1, events: [] })).status, 400);
});
test("feedback keeps the 5 per 10 min per session limit; the rest count as bad", async () => {
const DB = d1FromSchema();
const fb = Array.from({ length: 7 }, (_, i) => ev("feedback", { kind: "bug", note: "note " + i, platform: "iOS" }));
const res = await post(DB, { v: 1, events: fb });
assert.deepEqual(await res.json(), { ok: 5, dup: 0, bad: 2 });
assert.equal(rows(DB, "SELECT COUNT(*) n FROM feedback WHERE session='sess1'")[0].n, 5);
const more = await post(DB, { v: 1, events: [ev("feedback", { note: "again" })] });
assert.deepEqual(await more.json(), { ok: 0, dup: 0, bad: 1 });
});
test("run events keep long build_picks/weapons_list and store the timeline", async () => {
const DB = d1FromSchema();
const picks = Array.from({ length: 300 }, (_, i) => "pick" + i).join(","); // ~2.3 KB, was cut at 400
const timeline = { levelups: [[12.5, 2, ["a", "b", "c"], "b"]], xp_by_type: { swarmer: 40 }, damage_by_weapon: { blade: 900 }, bosses: [["boss", 80, 141.2]] };
await post(DB, { v: 1, events: [ev("run", { mode: "survival", build_picks: picks, weapons_list: "a".repeat(900), timeline, is_auto: false }, { auto: 1 })] });
const [r] = rows(DB, "SELECT * FROM runs");
assert.equal(r.build_picks, picks);
assert.equal(r.weapons_list.length, 900);
assert.deepEqual(JSON.parse(r.timeline_json), timeline);
assert.equal(r.is_auto, 1, "envelope auto wins over d.is_auto");
assert.equal(r.session, "sess1");
});
test("a hitch event fans out one hitches row", async () => {
const DB = d1FromSchema();
await post(DB, { v: 1, events: [ev("hitch", { frame_ms: 140, proc_ms: 90, pipelines: 2, state: "play", recent: "new:death", quality: 2 })] });
const [h] = rows(DB, "SELECT * FROM hitches");
assert.equal(h.frame_ms, 140); assert.equal(h.pipelines, 2); assert.equal(h.quality, 2); assert.equal(h.recent, "new:death");
assert.equal(h.event_id.length, 32);
});
test("an implausible client ts falls back to server time on legacy rows; events keeps both", async () => {
const DB = d1FromSchema();
const before = Date.now();
await post(DB, { v: 1, events: [ev("sample", {}, { ts: 5 })] });
const [s] = rows(DB, "SELECT ts FROM samples");
assert.ok(s.ts >= before);
const [e] = rows(DB, "SELECT ts, rts FROM events");
assert.equal(e.ts, s.ts); assert.ok(e.rts >= before);
});
// Old clients (store builds <= 206/1.0) keep using the per-route endpoints: prove those still write
// against the real schema, with the v2 columns NULL.
test("legacy /sample and /gameplay still work on the real schema", async () => {
const DB = d1FromSchema();
assert.equal((await post(DB, { build: 206, session: "old", fps: 60, max_frame_ms: 30, hitches: 0 }, "/sample")).status, 200);
assert.equal((await post(DB, { build: 206, session: "old", mode: "survival", build_picks: "x".repeat(1000) }, "/gameplay")).status, 200);
const [s] = rows(DB, "SELECT * FROM samples");
assert.equal(s.install_id, null); assert.equal(s.auto, null); assert.equal(s.gpu_ms, null);
const [r] = rows(DB, "SELECT * FROM runs");
assert.equal(r.build_picks.length, 1000, "legacy route gets the raised limit too");
assert.equal(r.timeline_json, null);
});
test("legacy /hitch batch and /encounter and /feedback still work on the real schema", async () => {
const DB = d1FromSchema();
assert.equal((await post(DB, { build: 206, session: "old", hitches: [{ frame_ms: 80 }, { frame_ms: 90 }] }, "/hitch")).status, 200);
assert.equal((await post(DB, { build: 206, session: "old", enemy_type: "spider" }, "/encounter")).status, 200);
assert.equal((await post(DB, { build: 206, session: "old", note: "hello" }, "/feedback")).status, 200);
assert.equal(rows(DB, "SELECT COUNT(*) n FROM hitches")[0].n, 2);
assert.equal(rows(DB, "SELECT COUNT(*) n FROM encounters")[0].n, 1);
assert.equal(rows(DB, "SELECT COUNT(*) n FROM feedback")[0].n, 1);
});
  • Step 5: Run the tests to verify they fail

Run: node --test telemetry/test/*.test.mjs Expected: FAIL. events.test.mjs fails to import EVENTS_MAX, and the legacy-on-schema tests fail with no such column because the Worker’s CREATE/INSERT lists and the schema don’t match yet. The existing channel/config/stamp tests PASS.

  • Step 6: Add the shared builders and /events to telemetry/src/worker.js

Update the header comment to list POST /events as the first line of the route list:

// POST /events — telemetry v2: a batch of ≤20 client events (dedupe by id; fans out to the legacy tables)

After const str = (v, n) => ...; (line 17) insert:

const numOrNull = (v) => (v == null ? null : num(v));
const json = (o, status = 200) =>
new Response(JSON.stringify(o), { status, headers: { ...CORS, "Content-Type": "application/json" } });
const insertSQL = (table, row) => {
const cols = Object.keys(row);
return `INSERT INTO ${table} (${cols.join(",")}) VALUES (${cols.map(() => "?").join(",")})`;
};
// Telemetry v2 limits. 20 events x 2 statements (+ <=5 feedback rate-limit SELECTs) stays under the
// D1 free plan's 50 queries per Worker invocation.
export const EVENTS_MAX = 20;
export const EVENTS_MAX_BYTES = 512 * 1024;
// One row builder per legacy table, shared by the old per-route handlers and the /events fan-out so
// the two can never drift. `s` = the payload (old routes: the request body; /events: `d` merged with
// the envelope). `c` = {ts, ua, install, launch, id}; old routes pass null for the last three.
// Object key order IS the INSERT column order.
export function sampleRow(s, c) {
return {
ts: c.ts, build: intt(s.build), session: str(s.session, 40),
fps: num(s.fps), frame_ms: num(s.frame_ms), sim_ms: num(s.sim_ms), render_ms: num(s.render_ms),
draws: intt(s.draws), prims: intt(s.prims), enemies: intt(s.enemies), zones: intt(s.zones), webs: intt(s.webs),
run_time: num(s.run_time), low_fx: s.low_fx ? 1 : 0, quality: intt(s.quality), dev: str(s.dev, 40), ua: c.ua,
max_frame_ms: num(s.max_frame_ms), hitches: intt(s.hitches), config_id: str(s.config_id, 40) || null,
channel: str(s.channel, 12) || null, channel_probe: str(s.channel_probe, 40) || null,
install_id: c.install, launch_id: c.launch, event_id: c.id,
auto: s.auto == null ? null : (s.auto ? 1 : 0),
gpu_ms: numOrNull(s.gpu_ms), render_cpu_ms: numOrNull(s.render_cpu_ms),
};
}
// h = one hitch; s = its batch (old /hitch) or the same merged payload (/events).
export function hitchRow(h, s, c) {
return {
ts: c.ts, build: intt(s.build), session: str(s.session, 40), dev: str(s.dev, 40), quality: intt(s.quality),
frame_ms: num(h.frame_ms), proc_ms: num(h.proc_ms), phys_ms: num(h.phys_ms), pipelines: intt(h.pipelines),
run_time: num(h.run_time), enemies: intt(h.enemies), draws: intt(h.draws), nodes: intt(h.nodes),
state: str(h.state, 16), recent: str(h.recent, 400),
channel: str(s.channel, 12) || null, channel_probe: str(s.channel_probe, 40) || null,
install_id: c.install, launch_id: c.launch, event_id: c.id,
};
}
export function runRow(s, c) {
return {
ts: c.ts, build: intt(s.build), session: str(s.session, 40), mode: str(s.mode, 12),
run_time: num(s.run_time), level: intt(s.level), kills: intt(s.kills), gold: intt(s.gold),
dmg_dealt: num(s.dmg_dealt), dps: num(s.dps), reactions: intt(s.reactions), region: str(s.region, 16), decoy: str(s.decoy, 16),
weapons: intt(s.weapons), tutorial_done: s.tutorial_done ? 1 : 0,
death_cause: str(s.death_cause, 16), peak_enemies: intt(s.peak_enemies), damage_taken: num(s.damage_taken),
// Raised 2026-09-29 (telemetry v2): 120/400 silently cut long builds mid-id.
weapons_list: str(s.weapons_list, 1000), build_picks: str(s.build_picks, 4000), dev: str(s.dev, 40),
kills_by_type: str(JSON.stringify(s.kills_by_type || {}), 2000),
ruleset: str(s.ruleset, 12), is_auto: s.is_auto ? 1 : 0, ua: c.ua,
pilots: s.pilots == null ? 1 : intt(s.pilots), // older builds don't send it: solo
level2: s.level2 == null ? null : intt(s.level2), // P2's level; NULL on solo runs
meta_spent: s.meta_spent == null ? null : intt(s.meta_spent), // gold invested in the shop; NULL = older build
meta_levels: s.meta_levels == null ? null : str(s.meta_levels, 2000), // "id:lvl,..." owned shop levels
config_id: str(s.config_id, 40) || null,
config_hash: s.config_hash == null ? null : intt(s.config_hash),
config_blacklisted: str(s.config_blacklisted, 200) || null,
channel: str(s.channel, 12) || null, channel_probe: str(s.channel_probe, 40) || null,
install_id: c.install, launch_id: c.launch, event_id: c.id,
timeline_json: s.timeline == null ? null : str(JSON.stringify(s.timeline), 60000),
};
}
export function encounterRow(s, c) {
return {
ts: c.ts, session: str(s.session, 40), build: intt(s.build), is_auto: s.is_auto ? 1 : 0,
enemy_type: str(s.enemy_type, 16), chain: s.chain ? 1 : 0, outcome: str(s.outcome, 12), player_dashed: s.player_dashed ? 1 : 0,
closest_approach_px: num(s.closest_approach_px), duration_s: num(s.duration_s),
samples_json: str(JSON.stringify(s.samples || []), 4000), config_id: str(s.config_id, 40) || null,
channel: str(s.channel, 12) || null, channel_probe: str(s.channel_probe, 40) || null,
install_id: c.install, launch_id: c.launch, event_id: c.id,
};
}
// null = not storable (a note under 3 characters).
export function feedbackRow(s, c) {
const note = str(s.note, 2000).trim();
if (note.length < 3) return null;
return {
ts: c.ts, session: str(s.session, 40), build: intt(s.build), dev: str(s.dev, 40), platform: str(s.platform, 16),
kind: ["bug", "idea", "other"].includes(s.kind) ? s.kind : "other", note,
context_json: str(JSON.stringify(s.context || {}), 4000), config_id: str(s.config_id, 40) || null,
channel: str(s.channel, 12) || null, channel_probe: str(s.channel_probe, 40) || null,
install_id: c.install, launch_id: c.launch, event_id: c.id,
};
}
const legacyCtx = (req) => ({ ts: Date.now(), ua: str(req.headers.get("user-agent") || "", 240), install: null, launch: null, id: null });
const LEGACY = { sample: "samples", hitch: "hitches", run: "runs", encounter: "encounters", feedback: "feedback" };
const BUILD_ROW = { sample: sampleRow, hitch: (s, c) => hitchRow(s, s, c), run: runRow, encounter: encounterRow };
function validEvent(ev) {
if (!ev || typeof ev !== "object" || Array.isArray(ev)) return null;
const id = typeof ev.id === "string" ? ev.id : "";
const t = typeof ev.t === "string" ? ev.t : "";
if (!/^[0-9a-f]{32}$/.test(id) || !/^[a-z_]{1,24}$/.test(t)) return null;
const d = ev.d && typeof ev.d === "object" && !Array.isArray(ev.d) ? ev.d : {};
return {
id, t, d, ts: ev.ts, install: str(ev.install, 40) || null, launch: str(ev.launch, 40) || null,
session: str(ev.session, 40), build: intt(ev.build), channel: str(ev.channel, 12), dev: str(ev.dev, 40),
auto: ev.auto ? 1 : 0,
};
}
// Client push time when believable (an offline-queued run lands when it was played), else now.
const plausibleTs = (t, now) => { const v = intt(t); return v > now - 30 * 86400000 && v < now + 86400000 ? v : now; };
async function handleEvents(req, env) {
if (Number(req.headers.get("content-length") || 0) > EVENTS_MAX_BYTES) return json({ error: "too large" }, 413);
const text = await req.text();
if (text.length > EVENTS_MAX_BYTES) return json({ error: "too large" }, 413);
let body;
try { body = JSON.parse(text); } catch { return json({ error: "bad json" }, 400); }
const evs = body && Array.isArray(body.events) ? body.events : null;
if (!evs || !evs.length) return json({ error: "no events" }, 400);
if (evs.length > EVENTS_MAX) return json({ error: "too many" }, 413);
const now = Date.now();
const ua = str(req.headers.get("user-agent") || "", 240);
const stmts = [];
const eventStmtIdx = []; // index in `stmts` of each accepted event's `events` insert
const fbLeft = new Map(); // session -> feedback notes still allowed in this 10-minute window
let bad = 0;
for (const raw of evs) {
const v = validEvent(raw);
if (!v) { bad++; continue; }
const c = { ts: plausibleTs(v.ts, now), ua, install: v.install, launch: v.launch, id: v.id };
// Envelope wins over the payload for the fields both carry.
const s = { ...v.d, build: v.build, session: v.session, channel: v.channel, dev: v.dev, is_auto: v.auto, auto: v.auto };
const table = LEGACY[v.t];
let row = null;
if (v.t === "feedback") {
row = feedbackRow(s, c);
if (!row) { bad++; continue; }
if (!fbLeft.has(v.session)) {
if (fbLeft.size >= 5) { bad++; continue; } // bounds the SELECTs below (D1 query budget)
let recent;
try {
recent = await env.DB.prepare("SELECT COUNT(*) AS n FROM feedback WHERE session = ? AND ts > ?")
.bind(v.session, now - 10 * 60 * 1000).first();
} catch { return json({ error: "db error" }, 500); }
fbLeft.set(v.session, 5 - (recent ? recent.n : 0));
}
if (fbLeft.get(v.session) <= 0) { bad++; continue; }
fbLeft.set(v.session, fbLeft.get(v.session) - 1);
} else if (table) {
row = BUILD_ROW[v.t](s, c);
}
if (row) {
const cols = Object.keys(row);
stmts.push(env.DB.prepare(
`INSERT INTO ${table} (${cols.join(",")}) SELECT ${cols.map(() => "?").join(",")} WHERE NOT EXISTS (SELECT 1 FROM events WHERE id = ?)`
).bind(...Object.values(row), v.id));
}
eventStmtIdx.push(stmts.length);
stmts.push(env.DB.prepare(
"INSERT OR IGNORE INTO events (id,ts,rts,type,install,launch,session,build,channel,dev,auto,data) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)"
).bind(v.id, c.ts, now, v.t, v.install, v.launch, v.session, v.build, v.channel || null, v.dev, v.auto,
table ? null : str(JSON.stringify(v.d), 480000)));
}
let ok = 0, dup = 0;
if (stmts.length) {
let res;
try { res = await env.DB.batch(stmts); } catch { return json({ error: "db error" }, 500); }
for (const i of eventStmtIdx) { if (res[i] && res[i].meta && res[i].meta.changes > 0) ok++; else dup++; }
}
return json({ ok, dup, bad });
}

Directly after if (req.method === "OPTIONS") ... in fetch, add:

if (req.method === "POST" && url.pathname === "/events") return handleEvents(req, env);

Replace the bodies of the five old POST routes so they use the builders. Keep each route’s parse, validation, status codes and responses exactly as they are:

if (req.method === "POST" && url.pathname === "/sample") {
let s;
try { s = JSON.parse(await req.text()); } catch { return new Response("bad json", { status: 400, headers: CORS }); }
try {
const row = sampleRow(s, legacyCtx(req));
await env.DB.prepare(insertSQL("samples", row)).bind(...Object.values(row)).run();
} catch (_e) {
return new Response("db error", { status: 500, headers: CORS });
}
return new Response("ok", { headers: CORS });
}
if (req.method === "POST" && url.pathname === "/hitch") {
let s;
try { s = JSON.parse(await req.text()); } catch { return new Response("bad json", { status: 400, headers: CORS }); }
const hs = Array.isArray(s.hitches) ? s.hitches.slice(0, 40) : [];
if (!hs.length) return new Response("empty", { status: 400, headers: CORS });
const c = legacyCtx(req);
try {
await env.DB.batch(hs.map((h) => { const row = hitchRow(h, s, c); return env.DB.prepare(insertSQL("hitches", row)).bind(...Object.values(row)); }));
} catch (_e) {
return new Response("db error", { status: 500, headers: CORS });
}
return new Response("ok", { headers: CORS });
}
if (req.method === "POST" && url.pathname === "/gameplay") {
let s;
try { s = JSON.parse(await req.text()); } catch { return new Response("bad json", { status: 400, headers: CORS }); }
try {
const row = runRow(s, legacyCtx(req));
await env.DB.prepare(insertSQL("runs", row)).bind(...Object.values(row)).run();
} catch (_e) {
return new Response("db error", { status: 500, headers: CORS });
}
return new Response("ok", { headers: CORS });
}
if (req.method === "POST" && url.pathname === "/encounter") {
let s;
try { s = JSON.parse(await req.text()); } catch { return new Response("bad json", { status: 400, headers: CORS }); }
try {
const row = encounterRow(s, legacyCtx(req));
await env.DB.prepare(insertSQL("encounters", row)).bind(...Object.values(row)).run();
} catch (_e) {
return new Response("db error", { status: 500, headers: CORS });
}
return new Response("ok", { headers: CORS });
}
if (req.method === "POST" && url.pathname === "/feedback") {
let s;
try { s = JSON.parse(await req.text()); } catch { return new Response("bad json", { status: 400, headers: CORS }); }
const row = feedbackRow(s, legacyCtx(req));
if (!row) return new Response("empty", { status: 400, headers: CORS });
try {
// Anonymous public endpoint: cap each session at 5 notes per 10 minutes.
const recent = await env.DB.prepare(
"SELECT COUNT(*) AS n FROM feedback WHERE session = ? AND ts > ?"
).bind(row.session, Date.now() - 10 * 60 * 1000).first();
if (recent && recent.n >= 5) return new Response("slow down", { status: 429, headers: CORS });
await env.DB.prepare(insertSQL("feedback", row)).bind(...Object.values(row)).run();
} catch (_e) {
return new Response("db error", { status: 500, headers: CORS });
}
return new Response("ok", { headers: CORS });
}
  • Step 7: Run the tests to verify they pass

Run: node --test telemetry/test/*.test.mjs Expected: PASS, all tests in channel, config, stamp and events. channel.test.mjs’s /channel,channel_probe/ still matches, because the feedback row keeps those two keys adjacent.

  • Step 8: Gate and commit

Run: bash scripts/gate.sh. Expected: == GATE PASS. There are no Godot changes, so this only proves nothing else moved.

Terminal window
cat > /tmp/bh-msg.txt <<'EOF'
feat(telemetry): POST /events with dedupe + legacy fan-out, events table, raised truncation
Telemetry v2 Task 1. One D1 batch per request: INSERT OR IGNORE into events, legacy
INSERT guarded by NOT EXISTS(events.id). 20 events/request (D1 50-query budget).
Legacy routes share the same row builders. Not deployed (Chris-gated).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gk
EOF
git add telemetry/schema.sql telemetry/migrations/2026-09-29-telemetry-v2.sql telemetry/src/worker.js telemetry/test/d1shim.mjs telemetry/test/events.test.mjs
git commit -F /tmp/bh-msg.txt

Task 2: EventQueue — pure core, store, file store, Node wrapper

Section titled “Task 2: EventQueue — pure core, store, file store, Node wrapper”

Files:

  • Create: net/event_queue_store.gd, net/event_queue_file_store.gd, net/event_queue_core.gd, net/event_queue.gd
  • Test: tests/test_event_queue_core.gd, tests/test_event_queue.gd

Interfaces:

  • Consumes: the Worker contract from Task 1 (POST /events, ≤20 events, {ok,dup,bad}).

  • Produces (used by Tasks 3-9):

    • EventQueue (Node):
      • setup(install_id: String, share: bool) -> void
      • push(t: String, d: Dictionary = {}) -> String (returns the event id, or "" when not enabled)
      • flush_now() -> void
      • set_session(session: String) -> void
      • set_enabled(on: bool, install_id: String) -> void
      • is_enabled() -> bool
      • launch_id() -> String
      • enable_for_test() -> void
      • var auto_fn: Callable
      • var muted: bool
      • var core: EventQueueCore
      • var sent_bodies: Array[String] (test mode)
      • static live_platform() -> bool
      • static new_id() -> String
    • EventQueueCore:
      • push(t, d, auto) -> String
      • pending_events() -> Array[Dictionary]
      • pending_count() -> int
      • pending_bytes() -> int
      • should_flush(now_s: float, force := false) -> bool
      • begin_flush(now_s: float) -> String
      • finish_flush(code: int, response: String, now_s: float) -> void
      • wipe() -> void
      • load_from_store() -> void
      • var ctx: Dictionary
      • var id_fn: Callable
      • var now_ms_fn: Callable
      • var max_bytes: int
      • var backoff_s: float
  • Ack rule (deviation 2): a batch leaves the queue on a 2xx whose ok+dup+bad == sent, or on 400/413 (the Worker will never accept it). Anything else (404 before the Worker is deployed, 5xx, 429, network) keeps it and backs off. So a v2 client that runs before the Worker deploy loses nothing up to the 1 MB cap.

  • Step 1: Write the failing core tests tests/test_event_queue_core.gd

extends GutTest
# EventQueueCore (telemetry v2): the pure queue logic -- envelope, on-disk line format, cap/drop
# order, batching, ack, backoff. Ids and clock are injected, so every assertion is exact.
func _core(store: EventQueueStore = null) -> EventQueueCore:
var c := EventQueueCore.new(store)
var n := [0] # an Array, so the lambda mutates shared state (lambdas capture locals by value)
c.id_fn = func() -> String:
n[0] += 1
return "%032x" % n[0]
c.now_ms_fn = func() -> int: return 1_700_000_000_000
c.ctx = {"install": "i1", "launch": "l1", "session": "s1", "build": 210, "channel": "dev", "dev": "iOS/iPhone15,2"}
return c
func _types(c: EventQueueCore) -> Array:
return c.pending_events().map(func(e: Dictionary) -> String: return String(e.t))
func test_push_builds_the_exact_envelope() -> void:
var c := _core()
var id := c.push("menu", {"a": 1}, true)
assert_eq(id, "%032x" % 1)
var e: Dictionary = c.pending_events()[0]
var keys := e.keys()
keys.sort()
assert_eq(keys, ["auto", "build", "channel", "d", "dev", "id", "install", "launch", "session", "t", "ts"])
assert_eq(e.t, "menu")
assert_eq(e.install, "i1")
assert_eq(e.launch, "l1")
assert_eq(e.session, "s1")
assert_eq(int(e.build), 210)
assert_eq(e.channel, "dev")
assert_eq(e.dev, "iOS/iPhone15,2")
assert_eq(e.auto, true)
assert_eq(int(e.ts), 1_700_000_000_000)
assert_eq(int(e.d.a), 1)
func test_store_lines_are_id_tab_type_tab_json() -> void:
var s := EventQueueStore.new()
var c := _core(s)
c.push("sample", {"note": "tab\there"})
var lines := s.read_all()
assert_eq(lines.size(), 1)
var parts := lines[0].split("\t")
assert_eq(parts.size(), 3, "a tab inside the payload is escaped by JSON.stringify, never raw")
assert_eq(parts[0], "%032x" % 1)
assert_eq(parts[1], "sample")
var parsed: Dictionary = JSON.parse_string(parts[2])
assert_eq(parsed.d.note, "tab\there")
func test_load_drops_malformed_and_torn_lines_and_keeps_order() -> void:
var s := EventQueueStore.new()
var a := _core(s)
a.push("menu", {})
a.push("run", {})
var lines := s.read_all()
s.rewrite(PackedStringArray([lines[0], "garbage", lines[1], lines[1].left(40)]))
var b := _core(s)
b.load_from_store()
assert_eq(_types(b), ["menu", "run"])
assert_eq(s.read_all().size(), 2, "the store is rewritten without the bad lines")
func test_over_the_cap_samples_go_first_then_the_oldest() -> void:
var c := _core()
var pad := "x".repeat(200)
c.push("run", {"pad": pad})
c.push("sample", {"pad": pad})
c.push("menu", {"pad": pad})
c.push("sample", {"pad": pad})
var one := c.pending_bytes() / 4.0 # float maths: integer division is a GDScript warning
c.max_bytes = int(one * 3.5) # room for three and a half lines
c.push("feedback", {"pad": pad}) # five lines: two must go, both samples
assert_eq(_types(c), ["run", "menu", "feedback"])
c.max_bytes = int(one * 2.5)
c.push("menu", {"pad": pad}) # no samples left: the oldest two go
assert_eq(_types(c), ["feedback", "menu"])
assert_eq(c.dropped, 4)
func test_flush_timing_boot_then_interval_or_pending_count() -> void:
var c := _core()
assert_false(c.should_flush(0.0), "nothing pending")
c.push("launch", {})
assert_true(c.should_flush(0.0), "boot: the first flush goes straight away")
c.begin_flush(0.0)
assert_false(c.should_flush(100.0), "one request in flight at a time")
c.finish_flush(200, '{"ok":1,"dup":0,"bad":0}', 0.5)
c.push("menu", {})
assert_false(c.should_flush(5.0), "under 10 s and under 20 pending")
assert_true(c.should_flush(10.0), "10 s since the last flush")
for k in EventQueueCore.FLUSH_PENDING:
c.push("menu", {})
assert_true(c.should_flush(1.0), "20+ pending flushes early")
func test_a_flush_sends_the_20_oldest_as_one_json_body() -> void:
var c := _core()
for k in 25:
c.push("menu", {"k": k})
var body := c.begin_flush(0.0)
var parsed: Dictionary = JSON.parse_string(body)
assert_eq(int(parsed.v), 1)
var evs: Array = parsed.events
assert_eq(evs.size(), EventQueueCore.BATCH)
assert_eq(int(evs[0].d.k), 0)
assert_eq(int(evs[19].d.k), 19)
func test_a_full_ack_drops_the_batch_and_resets_backoff() -> void:
var s := EventQueueStore.new()
var c := _core(s)
for k in 3:
c.push("menu", {})
c.backoff_s = 40.0
c.begin_flush(0.0)
c.finish_flush(200, '{"ok":2,"dup":1,"bad":0}', 0.0)
assert_eq(c.pending_count(), 0)
assert_eq(s.read_all().size(), 0, "the store is rewritten too")
assert_eq(c.backoff_s, 0.0)
func test_events_pushed_during_a_flush_survive_its_ack() -> void:
var c := _core()
c.push("menu", {})
c.push("menu", {})
c.begin_flush(0.0)
c.push("run", {})
c.finish_flush(200, '{"ok":2,"dup":0,"bad":0}', 0.0)
assert_eq(_types(c), ["run"])
func test_a_short_count_keeps_the_batch_and_backs_off_doubling_to_five_minutes() -> void:
var c := _core()
c.push("menu", {})
c.push("menu", {})
c.begin_flush(0.0)
c.finish_flush(200, '{"ok":1,"dup":0,"bad":0}', 0.0)
assert_eq(c.pending_count(), 2, "not every event was accounted for: resend (dedupe makes it safe)")
assert_eq(c.backoff_s, 10.0)
assert_false(c.should_flush(9.0))
assert_true(c.should_flush(10.0))
var t := 10.0
for k in 8:
c.begin_flush(t)
c.finish_flush(0, "", t)
t += c.backoff_s
assert_eq(c.backoff_s, EventQueueCore.BACKOFF_MAX_S)
func test_400_and_413_drop_the_batch_but_404_5xx_429_and_network_keep_it() -> void:
for code in [400, 413]:
var c := _core()
c.push("menu", {})
c.begin_flush(0.0)
c.finish_flush(code, "", 0.0)
assert_eq(c.pending_count(), 0, "HTTP %d drops" % code)
for code in [404, 429, 500, 0]:
var c := _core()
c.push("menu", {})
c.begin_flush(0.0)
c.finish_flush(code, "", 0.0)
assert_eq(c.pending_count(), 1, "HTTP %d keeps" % code)
func test_force_bypasses_backoff_and_interval_but_not_an_in_flight_request() -> void:
var c := _core()
c.push("menu", {})
c.begin_flush(0.0)
assert_false(c.should_flush(0.0, true), "still one request at a time")
c.finish_flush(500, "", 0.0)
assert_false(c.should_flush(1.0))
assert_true(c.should_flush(1.0, true), "background/run-end flush ignores the backoff")
func test_wipe_clears_memory_and_store() -> void:
var s := EventQueueStore.new()
var c := _core(s)
c.push("menu", {})
c.wipe()
assert_eq(c.pending_count(), 0)
assert_eq(c.pending_bytes(), 0)
assert_eq(s.read_all().size(), 0)
func test_file_store_round_trip_persists_and_clear_removes_the_file() -> void:
var name := "eq_test_%d.txt" % Time.get_ticks_usec()
var fs := EventQueueFileStore.new(name)
fs.append("a\tb\t{}")
fs.append("c\td\t{}")
assert_eq(fs.read_all(), PackedStringArray(["a\tb\t{}", "c\td\t{}"]))
fs.rewrite(PackedStringArray(["c\td\t{}"]))
assert_eq(EventQueueFileStore.new(name).read_all(), PackedStringArray(["c\td\t{}"]), "a new instance reads it back")
fs.clear()
assert_eq(fs.read_all().size(), 0)
assert_false(FileAccess.file_exists("user://" + name))
fs.append("e\tf\t{}")
assert_eq(fs.read_all().size(), 1, "append after clear recreates the file")
fs.clear()
  • Step 2: Write the failing Node tests tests/test_event_queue.gd
extends GutTest
# EventQueue node: the gates (headless / muted / share off), session + auto stamping, and the
# flush hand-off. enable_for_test() swaps in an in-memory store and captures bodies instead of
# sending them.
func _q() -> EventQueue:
var q := EventQueue.new()
add_child_autofree(q)
q.setup("inst", true)
return q
func test_headless_setup_is_a_silent_no_op() -> void:
var q := _q()
assert_false(q.is_enabled())
assert_eq(q.push("menu"), "", "headless: nothing is queued")
assert_eq(q.core.pending_count(), 0)
func test_setup_mints_a_launch_id_and_stamps_the_context() -> void:
var q := _q()
assert_eq(q.launch_id().length(), 32)
assert_eq(q.core.ctx.install, "inst")
assert_eq(int(q.core.ctx.build), Sim_Const.BUILD)
assert_eq(q.core.ctx.channel, BuildChannel.current())
assert_eq(q.core.ctx.dev, TelemetryDevice.id())
var other := _q()
assert_ne(other.launch_id(), q.launch_id(), "a new launch id per process start")
func test_push_stamps_the_session_and_auto() -> void:
var q := _q()
q.enable_for_test()
q.set_session("run1")
q.auto_fn = func() -> bool: return true
var id := q.push("run_start", {"mode": "survival"})
assert_eq(id.length(), 32)
var e: Dictionary = q.core.pending_events()[0]
assert_eq(e.session, "run1")
assert_eq(e.auto, true)
assert_eq(e.d.mode, "survival")
func test_muted_push_is_a_no_op_and_keeps_the_backlog() -> void:
var q := _q()
q.enable_for_test()
q.push("menu")
q.muted = true
assert_eq(q.push("menu"), "")
assert_eq(q.core.pending_count(), 1)
func test_flush_now_hands_one_body_to_the_sender() -> void:
var q := _q()
q.enable_for_test()
q.push("menu")
q.push("menu")
q.flush_now()
assert_eq(q.sent_bodies.size(), 1)
var parsed: Dictionary = JSON.parse_string(q.sent_bodies[0])
assert_eq((parsed.events as Array).size(), 2)
func test_turning_off_wipes_and_stops_pushes_and_back_on_takes_the_new_install_id() -> void:
var q := _q()
q.enable_for_test()
q.push("menu")
q.set_enabled(false, "")
assert_eq(q.core.pending_count(), 0, "the queue is wiped")
assert_eq(q.push("menu"), "")
q.set_enabled(true, "fresh")
q.push("menu")
assert_eq(q.core.pending_events()[0].install, "fresh")
  • Step 3: Run the tests to verify they fail

Run: godot --headless --path . --import; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_event_queue -gexit Expected: FAIL (parse errors: EventQueueCore / EventQueue not declared). -gselect=test_event_queue matches both files.

  • Step 4: Create net/event_queue_store.gd
class_name EventQueueStore
extends RefCounted
# Where EventQueueCore mirrors its pending lines (telemetry v2). This base keeps them in memory
# (editor, headless, GUT); EventQueueFileStore persists the same four calls to disk.
var _mem: PackedStringArray = PackedStringArray()
func append(line: String) -> void:
_mem.append(line)
func read_all() -> PackedStringArray:
return _mem.duplicate()
func rewrite(lines: PackedStringArray) -> void:
_mem = lines.duplicate()
func clear() -> void:
_mem = PackedStringArray()
  • Step 5: Create net/event_queue_file_store.gd
class_name EventQueueFileStore
extends EventQueueStore
# The on-disk telemetry queue (telemetry v2). One line per event, appended on push (never a full
# rewrite per event); rewritten only on ack, cap and load cleanup. user:// first; tvOS cannot write
# user:// so fall back to the cache dir (same rule as LocalJson / MetaStore). No subdirectory:
# tvOS cannot create one. Best-effort on tvOS, whose cache dir can be purged.
const FILE := "telemetry_queue.txt"
var path: String = ""
func _init(file_name: String = FILE) -> void:
path = _pick_path(file_name)
static func _pick_path(file_name: String) -> String:
var candidates: Array[String] = ["user://" + file_name, OS.get_cache_dir().path_join(file_name)]
for p in candidates:
if FileAccess.file_exists(p):
return p
for p in candidates:
var f := FileAccess.open(p, FileAccess.WRITE)
if f != null:
f.close()
return p
return ""
func append(line: String) -> void:
if path == "":
return
var f: FileAccess = null
if FileAccess.file_exists(path):
f = FileAccess.open(path, FileAccess.READ_WRITE)
else:
f = FileAccess.open(path, FileAccess.WRITE)
if f == null:
return
f.seek_end()
f.store_string(line + "\n")
f.close()
func read_all() -> PackedStringArray:
if path == "" or not FileAccess.file_exists(path):
return PackedStringArray()
return FileAccess.get_file_as_string(path).split("\n", false)
func rewrite(lines: PackedStringArray) -> void:
if path == "":
return
var f := FileAccess.open(path, FileAccess.WRITE)
if f == null:
return
if not lines.is_empty():
f.store_string("\n".join(lines) + "\n")
f.close()
func clear() -> void:
if path != "" and FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))
  • Step 6: Create net/event_queue_core.gd
class_name EventQueueCore
extends RefCounted
# Telemetry v2 queue logic, pure (no Node, no HTTP). It builds the event envelope, keeps the
# pending lines (mirrored to an EventQueueStore), caps their size, cuts batches and applies the
# server's ack. Line format "<id>\t<type>\t<json envelope>": ack and the cap never re-parse JSON,
# and JSON.stringify escapes tabs, so a payload can never add a field.
# Spec: docs/superpowers/specs/2026-09-29-telemetry-v2-design.md (deviations in the plan).
const MAX_BYTES := 1048576
const BATCH := 20 # = Worker EVENTS_MAX (D1: 2 statements per event, 50 per request)
const BATCH_MAX_BYTES := 400000 # the Worker refuses bodies over 512 KB
const FLUSH_PENDING := 20
const FLUSH_INTERVAL_S := 10.0
const BACKOFF_MIN_S := 10.0
const BACKOFF_MAX_S := 300.0
var store: EventQueueStore
var ctx: Dictionary = {"install": "", "launch": "", "session": "", "build": 0, "channel": "", "dev": ""}
var id_fn: Callable # -> String, 32 hex chars
var now_ms_fn: Callable # -> int, unix ms
var max_bytes: int = MAX_BYTES
var backoff_s: float = 0.0
var last_flush_s: float = -INF
var next_try_s: float = -INF
var dropped: int = 0 # lines the cap threw away (diagnostic)
var _lines: PackedStringArray = PackedStringArray()
var _bytes: int = 0
var _in_flight: PackedStringArray = PackedStringArray() # ids of the batch being sent
func _init(s: EventQueueStore = null) -> void:
store = s if s != null else EventQueueStore.new()
id_fn = func() -> String: return Crypto.new().generate_random_bytes(16).hex_encode()
now_ms_fn = func() -> int: return int(Time.get_unix_time_from_system() * 1000.0)
static func _id_of(line: String) -> String:
return line.get_slice("\t", 0)
static func _type_of(line: String) -> String:
return line.get_slice("\t", 1)
static func _size_of(line: String) -> int:
return line.to_utf8_buffer().size() + 1 # + the newline on disk
static func _well_formed(line: String) -> bool:
return line.get_slice_count("\t") == 3 and _id_of(line).length() == 32 \
and line.get_slice("\t", 2).begins_with("{") and line.ends_with("}")
func make_event(t: String, d: Dictionary, auto: bool) -> Dictionary:
return {"id": String(id_fn.call()), "t": t, "ts": int(now_ms_fn.call()),
"install": String(ctx.get("install", "")), "launch": String(ctx.get("launch", "")),
"session": String(ctx.get("session", "")), "build": int(ctx.get("build", 0)),
"channel": String(ctx.get("channel", "")), "dev": String(ctx.get("dev", "")),
"auto": auto, "d": d}
func push(t: String, d: Dictionary, auto: bool = false) -> String:
var ev := make_event(t, d, auto)
var line := "%s\t%s\t%s" % [ev.id, t, JSON.stringify(ev)]
_lines.append(line)
_bytes += _size_of(line)
store.append(line)
_enforce_cap()
return String(ev.id)
func load_from_store() -> void:
_lines = PackedStringArray()
_bytes = 0
var had_bad := false
for line in store.read_all():
if _well_formed(line):
_lines.append(line)
_bytes += _size_of(line)
else:
had_bad = true # a torn last line from a kill mid-append, or garbage
if had_bad:
store.rewrite(_lines)
_enforce_cap()
func pending_count() -> int:
return _lines.size()
func pending_bytes() -> int:
return _bytes
func pending_events() -> Array[Dictionary]:
var out: Array[Dictionary] = []
for line in _lines:
var parsed: Variant = JSON.parse_string(line.get_slice("\t", 2))
if parsed is Dictionary:
out.append(parsed)
return out
func in_flight() -> bool:
return not _in_flight.is_empty()
# Over the cap: the oldest "sample" goes first (perf rows are the most plentiful and least
# precious), then the oldest line of any type.
func _enforce_cap() -> void:
var changed := false
while _bytes > max_bytes and not _lines.is_empty():
var victim := 0
for i in _lines.size():
if _type_of(_lines[i]) == "sample":
victim = i
break
_bytes -= _size_of(_lines[victim])
_lines.remove_at(victim)
dropped += 1
changed = true
if changed:
store.rewrite(_lines)
# last_flush_s starts at -INF, so the first call after boot flushes straight away. `force`
# (background, run end, feedback) ignores the interval and the backoff, never an in-flight request.
func should_flush(now_s: float, force: bool = false) -> bool:
if in_flight() or _lines.is_empty():
return false
if force:
return true
if now_s < next_try_s:
return false
return _lines.size() >= FLUSH_PENDING or now_s - last_flush_s >= FLUSH_INTERVAL_S
# The oldest <= BATCH lines (and <= BATCH_MAX_BYTES, but always at least one) as the POST body.
func begin_flush(now_s: float) -> String:
var parts := PackedStringArray()
var size := 0
_in_flight = PackedStringArray()
for line in _lines:
if parts.size() >= BATCH:
break
var j := line.get_slice("\t", 2)
if not parts.is_empty() and size + j.length() > BATCH_MAX_BYTES:
break
parts.append(j)
_in_flight.append(_id_of(line))
size += j.length() + 1
last_flush_s = now_s
if parts.is_empty():
return ""
return "{\"v\":1,\"events\":[%s]}" % ",".join(parts)
func finish_flush(code: int, response: String, now_s: float) -> void:
var sent := _in_flight.size()
var done := false
if code >= 200 and code < 300:
var parsed: Variant = JSON.parse_string(response)
if parsed is Dictionary:
var r: Dictionary = parsed
done = int(r.get("ok", 0)) + int(r.get("dup", 0)) + int(r.get("bad", 0)) == sent
elif code == 400 or code == 413:
done = true # the Worker will never accept this batch: retrying would wedge the queue
if done:
ack(_in_flight)
backoff_s = 0.0
next_try_s = -INF
else:
backoff_s = BACKOFF_MIN_S if backoff_s <= 0.0 else minf(backoff_s * 2.0, BACKOFF_MAX_S)
next_try_s = now_s + backoff_s
_in_flight = PackedStringArray()
func ack(ids: PackedStringArray) -> void:
if ids.is_empty():
return
var keep := PackedStringArray()
_bytes = 0
for line in _lines:
if not ids.has(_id_of(line)):
keep.append(line)
_bytes += _size_of(line)
_lines = keep
store.rewrite(_lines)
func wipe() -> void:
_lines = PackedStringArray()
_bytes = 0
_in_flight = PackedStringArray()
store.clear()
  • Step 7: Create net/event_queue.gd
class_name EventQueue
extends Node
# Telemetry v2: the ONE pipe every reporter pushes through (spec
# docs/superpowers/specs/2026-09-29-telemetry-v2-design.md). Persistent: created once in
# main._ready and never swept by _new_run / _return_to_menu. EventQueueCore holds the logic; this
# node adds the HTTP request, the flush clock and the gates. push() is a silent no-op in the
# editor/headless, while `muted` (capture / replay / showcase) and when the player has turned
# "Share diagnostics" off. Never affects play.
const ENDPOINT := "https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/events"
const HTTP_TIMEOUT_S := 15.0
var core: EventQueueCore = EventQueueCore.new()
var auto_fn: Callable # main: true while the attract-mode AI is flying the current run
var muted: bool = false # main: capture / replay / showcase runs never report
var sent_bodies: Array[String] = [] # test mode only: what a flush would have POSTed
var _enabled: bool = false
var _test_mode: bool = false
var _http: HTTPRequest
var _clock_s: float = 0.0 # monotonic seconds of _process time (render-side only)
static func live_platform() -> bool:
return not OS.has_feature("editor") and DisplayServer.get_name() != "headless"
static func new_id() -> String:
return Crypto.new().generate_random_bytes(16).hex_encode()
func setup(install_id: String, share: bool) -> void:
var live := live_platform()
var store: EventQueueStore = EventQueueStore.new()
if live:
store = EventQueueFileStore.new()
core = EventQueueCore.new(store)
core.ctx = {"install": install_id, "launch": new_id(), "session": "", "build": Sim_Const.BUILD,
"channel": BuildChannel.current(), "dev": TelemetryDevice.id()}
_enabled = live and share
if not live:
return
if share:
core.load_from_store()
else:
core.wipe() # switched off last session: nothing queued before that may leave the device
func launch_id() -> String:
return String(core.ctx.get("launch", ""))
func is_enabled() -> bool:
return _enabled and not muted
# GUT: an in-memory store, pushes enabled, and flushes captured in sent_bodies instead of sent.
func enable_for_test() -> void:
core.store = EventQueueStore.new()
_test_mode = true
_enabled = true
set_process(false) # tests drive flushes explicitly
func set_session(session: String) -> void:
core.ctx["session"] = session
# The pause-menu toggle (Task 7). Off wipes the queue; on takes the freshly rotated install id.
func set_enabled(on: bool, install_id: String) -> void:
core.ctx["install"] = install_id
if on:
_enabled = _test_mode or live_platform()
return
_enabled = false
core.wipe()
func push(t: String, d: Dictionary = {}) -> String:
if not is_enabled():
return ""
var auto := false
if auto_fn.is_valid():
auto = bool(auto_fn.call())
return core.push(t, d, auto)
func flush_now() -> void:
_try_flush(true)
func _process(dt: float) -> void:
_clock_s += dt
_try_flush(false)
func _try_flush(force: bool) -> void:
if not is_enabled() or not core.should_flush(_clock_s, force):
return
var body := core.begin_flush(_clock_s)
if body == "":
return
if _test_mode:
sent_bodies.append(body)
return # a test completes it with core.finish_flush(...)
if _http == null:
_http = HTTPRequest.new()
_http.timeout = HTTP_TIMEOUT_S
add_child(_http)
_http.request_completed.connect(_on_done)
# text/plain keeps it a CORS "simple request" (no preflight) for the web build.
if _http.request(ENDPOINT, ["Content-Type: text/plain"], HTTPClient.METHOD_POST, body) != OK:
core.finish_flush(0, "", _clock_s)
func _on_done(result: int, code: int, _h: PackedStringArray, body: PackedByteArray) -> void:
var c := code if result == HTTPRequest.RESULT_SUCCESS else 0
core.finish_flush(c, body.get_string_from_utf8(), _clock_s)
  • Step 8: Run the tests to verify they pass

Run: godot --headless --path . --import; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_event_queue -gexit Expected: PASS (both files, 0 failures, no un-asserted errors).

  • Step 9: Gate and commit

Run: bash scripts/gate.sh → == GATE PASS. The determinism test is inside it and must pass unchanged. Nothing uses the queue yet.

Terminal window
cat > /tmp/bh-msg.txt <<'EOF'
feat(telemetry): EventQueue — pure core, disk store, Node wrapper
Telemetry v2 Task 2. Envelope, "<id>\t<type>\t<json>" lines, 1 MB cap (samples dropped
first), 20-event batches, ack on ok+dup+bad==sent or 400/413, backoff 10 s -> 5 min.
Not wired to any reporter yet.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gk
EOF
git add net/event_queue_store.gd net/event_queue_file_store.gd net/event_queue_core.gd net/event_queue.gd tests/test_event_queue_core.gd tests/test_event_queue.gd
git commit -F /tmp/bh-msg.txt

(*.uid files are gitignored, so there is nothing else to add.)


Task 3: Move the reporters onto the queue, with one session id and install/launch ids

Section titled “Task 3: Move the reporters onto the queue, with one session id and install/launch ids”

Files:

  • Modify (full rewrite shown): net/telemetry.gd, net/gameplay_telemetry.gd
  • Modify: net/dash_encounter_telemetry.gd: the header vars, setup(), _send()
  • Modify: main.gd:
    • vars ~99-105
    • _ready ~317-334
    • _setup_showcase ~424-428
    • pause feedback ~530-531
    • _return_to_menu ~679-700
    • _new_run ~833-842, ~883-884, ~1266-1277
    • _bank_run ~1588-1590
  • Modify: tests/test_hitch_tracker.gd, rewriting test_telemetry_node_queues_a_hitch_with_context (lines 86-100)
  • Test: tests/test_telemetry_reporters_queue.gd

Interfaces:

  • Consumes: EventQueue from Task 2.

  • Produces:

    • Telemetry:
      • Telemetry.setup(sim: Sim, queue: EventQueue) -> void
      • Telemetry._send_sample() -> void
    • GameplayTelemetry:
      • GameplayTelemetry.setup(queue: EventQueue = null) -> void
      • GameplayTelemetry.report_run(sim, mode, decoy_type, region, tutorial_done, build_picks := [], is_auto := false, meta_spent := 0, meta_levels := "") -> void (the old session param is gone)
      • static GameplayTelemetry.run_body(...) -> Dictionary
      • GameplayTelemetry.send_feedback(kind, note, sim, input_style) -> void (the session param is gone)
      • static feedback_body(...) keeps its exact signature, because 3 existing tests call it.
    • DashEncounterTelemetry.setup(queue: EventQueue = null) -> void
    • main:
      • var event_queue: EventQueue
      • the session is set with event_queue.set_session(_run_session) at each run start and "" at the menu.
  • Payload rule: d never repeats envelope fields. There is no build/channel/channel_probe/session/dev inside d; the Worker merges the envelope back in for the legacy tables.

  • All reporters now share one session per run, including encounters (previously a per-boot id). Feedback carries the run’s session, or "" from a menu.

  • Step 1: Write the failing tests tests/test_telemetry_reporters_queue.gd

extends GutTest
# Telemetry v2 Task 3: every reporter pushes onto the EventQueue with the right type, and the
# payload never repeats the envelope (session/build/channel/dev live on the event itself).
func _queue() -> EventQueue:
var q := EventQueue.new()
add_child_autofree(q)
q.setup("inst", true)
q.enable_for_test()
q.set_session("run1")
return q
func _sim() -> Sim:
return Sim.new(1, SimContentFixture.db())
func _only(q: EventQueue) -> Dictionary:
var evs := q.core.pending_events()
assert_eq(evs.size(), 1, "exactly one event queued")
return evs[0] if evs.size() > 0 else {}
func _no_envelope_fields(d: Dictionary) -> void:
for k in ["build", "channel", "channel_probe", "session", "dev"]:
assert_false(d.has(k), "payload must not repeat envelope field %s" % k)
func test_a_perf_sample_is_a_sample_event() -> void:
var q := _queue()
var t := Telemetry.new()
add_child_autofree(t)
t.setup(_sim(), q)
t._enabled = true # headless gate forced on
t._send_sample()
var e := _only(q)
assert_eq(e.t, "sample")
assert_eq(e.session, "run1")
assert_true(e.d.has("fps"))
assert_true(e.d.has("max_frame_ms"))
assert_true(e.d.has("config_id"))
_no_envelope_fields(e.d)
func test_a_run_summary_is_a_run_event() -> void:
var q := _queue()
var g := GameplayTelemetry.new()
add_child_autofree(g)
g.setup(q)
g._enabled = true
g.report_run(_sim(), "survival", "basic", "", false, ["blade", "vitality"], false, 5, "vitality:1")
var e := _only(q)
assert_eq(e.t, "run")
assert_eq(e.d.mode, "survival")
assert_eq(e.d.build_picks, "blade,vitality")
assert_eq(int(e.d.meta_spent), 5)
_no_envelope_fields(e.d)
func test_feedback_is_a_feedback_event_and_flushes_now() -> void:
var q := _queue()
var g := GameplayTelemetry.new()
add_child_autofree(g)
g.setup(q)
g._enabled = true
g.send_feedback("bug", "it crashed", null, "touch")
var e := _only(q)
assert_eq(e.t, "feedback")
assert_eq(e.d.kind, "bug")
assert_eq(e.d.note, "it crashed")
assert_eq(e.d.context.input, "touch")
assert_eq(q.sent_bodies.size(), 1, "feedback is flushed straight away")
_no_envelope_fields(e.d)
func test_an_encounter_is_an_encounter_event() -> void:
var q := _queue()
var d := DashEncounterTelemetry.new()
add_child_autofree(d)
d.setup(q)
d._enabled = true
d._send({"enemy_type": "charger", "chain": false, "outcome": "clear", "player_dashed": true,
"closest_approach_px": 30.0, "duration_s": 1.2, "samples": []})
var e := _only(q)
assert_eq(e.t, "encounter")
assert_eq(e.session, "run1", "encounters now join the run's session")
assert_eq(e.d.enemy_type, "charger")
_no_envelope_fields(e.d)
func test_reporters_stay_silent_headless_without_the_gate_forced() -> void:
var q := _queue()
var g := GameplayTelemetry.new()
add_child_autofree(g)
g.setup(q) # headless: _enabled stays false
g.report_run(_sim(), "survival", "basic", "", false)
g.send_feedback("bug", "hello there", null, "pad")
assert_eq(q.core.pending_count(), 0)
  • Step 2: Rewrite the hitch node test in tests/test_hitch_tracker.gd

Replace test_telemetry_node_queues_a_hitch_with_context (lines 84-100, including its comment) with:

# The Telemetry node's per-frame path (disabled headless, so force it on) pushes a stall with its
# context onto the EventQueue as a "hitch" event.
func test_telemetry_node_queues_a_hitch_with_context() -> void:
HitchTracker.reset_seen() # so the tags below are first-use ("new:") in this process
var q := EventQueue.new()
add_child_autofree(q)
q.setup("inst", true)
q.enable_for_test()
var t := Telemetry.new()
add_child_autofree(t)
t.setup(Sim.new(1, SimContentFixture.db()), q)
t._enabled = true
t.state_fn = func() -> String: return "levelup"
t.hitches.frame(Time.get_ticks_usec() - 200_000) # the previous frame, 200ms ago
t.note_events([{"kind": "reaction", "name": "WARDEN"}])
t._track_frame()
var evs := q.core.pending_events()
assert_eq(evs.size(), 1)
var e: Dictionary = evs[0]
assert_eq(e.t, "hitch")
assert_gte(float(e.d.frame_ms), 200.0)
assert_eq(e.d.state, "levelup")
assert_string_contains(String(e.d.recent), "reaction:WARDEN")
assert_string_contains(String(e.d.recent), "state:levelup")
  • Step 3: Run the tests to verify they fail

Run: godot --headless --path . --import; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_telemetry_reporters_queue -gexit Expected: FAIL. setup() takes the wrong arguments, and _send_sample doesn’t exist.

  • Step 4: Rewrite net/telemetry.gd
class_name Telemetry
extends Node
# Render-side perf reporter (NOT /sim). Every INTERVAL seconds pushes a "sample" event, and every
# stalled frame a "hitch" event, onto the persistent EventQueue (telemetry v2), which owns the
# network, the disk queue and retries. Created fresh per run by main. Silent in editor/headless and
# never affects play.
const INTERVAL := 5.0
const PIPELINE_MONITORS := [
Performance.PIPELINE_COMPILATIONS_CANVAS, Performance.PIPELINE_COMPILATIONS_MESH,
Performance.PIPELINE_COMPILATIONS_SURFACE, Performance.PIPELINE_COMPILATIONS_DRAW,
Performance.PIPELINE_COMPILATIONS_SPECIALIZATION,
]
var low_fx: bool = false # set by main: true whenever adaptive quality has stepped below full
var quality: int = 0 # current adaptive quality tier (0 = full, higher = more stripped)
var _sim: Sim
var _queue: EventQueue
var _accum: float = 0.0
var _enabled: bool = false
# Hitch capture (2026-09-26): every stalled frame, with what happened just before it.
var hitches := HitchTracker.new()
var state_fn: Callable # set by main: returns "play" / "levelup" / "pause" / ...
var _pipelines_last: int = -1
var _last_state: String = ""
func setup(sim: Sim, queue: EventQueue) -> void:
_sim = sim
_queue = queue
_enabled = queue != null and EventQueue.live_platform()
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_PAUSED or what == NOTIFICATION_APPLICATION_FOCUS_OUT \
or what == NOTIFICATION_APPLICATION_RESUMED or what == NOTIFICATION_APPLICATION_FOCUS_IN:
hitches.skip_next()
# Main forwards each frame's drained render fx events, so a stall can be tied to the effect that
# was first drawn just before it (first use of a shader is the usual suspect).
func note_events(events: Array) -> void:
if not _enabled:
return
for ev in events:
var kind := String(ev.get("kind", ""))
if kind == "reaction" and ev.has("name"):
kind = "reaction:" + String(ev.name)
hitches.note(kind)
func note(tag: String) -> void:
if _enabled:
hitches.note(tag)
static func pipelines_total() -> int:
var n := 0
for m in PIPELINE_MONITORS:
n += int(Performance.get_monitor(m))
return n
func _push(t: String, d: Dictionary) -> void:
if _queue != null:
_queue.push(t, d)
func _track_frame() -> void:
var f := hitches.frame(Time.get_ticks_usec())
var pipes := pipelines_total()
var new_pipes := 0 if _pipelines_last < 0 else pipes - _pipelines_last
_pipelines_last = pipes
if new_pipes > 0:
hitches.note("pipelines")
var state := String(state_fn.call()) if state_fn.is_valid() else ""
if state != _last_state:
_last_state = state
hitches.note("state:" + state) # a panel opening / boss arriving, right before a stall
if not bool(f.hitch) or not hitches.can_send():
return
hitches.sent += 1
_push("hitch", _hitch_dict(float(f.ms), new_pipes, state))
func _hitch_dict(frame_ms: float, new_pipes: int, state: String) -> Dictionary:
return {
"frame_ms": frame_ms,
# TIME_PROCESS = the whole frame's process time, NOT script-only (see dev-tools.md Telemetry v2).
"proc_ms": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0,
"phys_ms": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0,
"pipelines": new_pipes,
"run_time": _sim.run_time,
"enemies": _sim.enemies.count,
"draws": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
"nodes": Performance.get_monitor(Performance.OBJECT_NODE_COUNT),
"state": state,
"recent": hitches.recent_tags(),
"quality": quality,
}
func _send_sample() -> void:
var win := hitches.take_window()
var fps := float(Engine.get_frames_per_second())
_push("sample", {
"config_id": RemoteConfig.active_id,
"fps": fps,
"frame_ms": 1000.0 / maxf(fps, 1.0),
"sim_ms": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0,
"render_ms": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0, # whole-frame TIME_PROCESS (legacy name)
"draws": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
"prims": Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME),
"enemies": _sim.enemies.count,
"zones": _sim.zones.size(),
"webs": _sim.webs.size(),
"run_time": _sim.run_time,
"low_fx": low_fx,
"quality": quality,
"max_frame_ms": win.max_frame_ms, # worst single frame in this 5s window
"hitches": win.hitches, # frames over HitchTracker.HITCH_MS in it
})
func _process(dt: float) -> void:
if not _enabled or _sim == null:
return
_track_frame()
_accum += dt
if _accum < INTERVAL:
return
_accum = 0.0
_send_sample()
  • Step 5: Rewrite net/gameplay_telemetry.gd
class_name GameplayTelemetry
extends Node
# Render-side (NOT /sim) gameplay reporter. Pushes a per-run SUMMARY ("run" event) at run end and
# pause-menu feedback ("feedback" event) onto the EventQueue (telemetry v2), which owns the network,
# retries and the disk queue. Active on real devices only; never affects play.
var _queue: EventQueue
var _enabled: bool = false
# The config id boot() blacklisted (RemoteConfigState.newly_blacklisted), reported on the first
# run summary after boot only, then cleared -- so it doesn't repeat on every run report this boot.
var _pending_blacklisted: String = ""
func setup(queue: EventQueue = null) -> void:
_queue = queue
_enabled = queue != null and EventQueue.live_platform()
# Called once at boot (main.gd) when RemoteConfigState.newly_blacklisted is non-empty.
func note_blacklisted(config_id: String) -> void:
_pending_blacklisted = config_id
# The run summary payload. `build_picks` is every upgrade taken this run; `is_auto` (2026-07-17)
# is main's `_auto`, i.e. "no human ever touched the controls this run" (the envelope's `auto`
# carries the same value and wins at the Worker). Session/build/channel/dev ride on the envelope.
static func run_body(sim: Sim, mode: String, decoy_type: String, region: String, tutorial_done: bool,
build_picks: Array, is_auto: bool, meta_spent: int, meta_levels: String, blacklisted: String) -> Dictionary:
var kbt := {}
for k in sim.kills_by_type:
kbt[str(k)] = sim.kills_by_type[k]
var t: float = maxf(sim.run_time, 0.001)
return {
"mode": mode,
"config_id": RemoteConfig.active_id, "config_hash": RemoteConfig.active_hash,
"config_blacklisted": blacklisted if blacklisted != "" else null,
"run_time": sim.run_time, "level": sim.player.level, "kills": sim.kills,
"gold": sim.run_gold, "dmg_dealt": sim.dmg_dealt_total, "dps": sim.dmg_dealt_total / t,
"reactions": sim.reactions_total, "kills_by_type": kbt,
"region": region, "decoy": decoy_type, "weapons": sim.active_weapon_ids.size(),
"tutorial_done": tutorial_done,
"death_cause": sim.death_cause, "peak_enemies": sim.peak_enemies,
"damage_taken": sim.damage_taken_total,
"weapons_list": ",".join(sim.active_weapon_ids),
"build_picks": ",".join(build_picks),
"ruleset": "survival" if sim.ruleset == Sim.RULESET_SURVIVAL else "reactions",
"is_auto": is_auto,
# Co-op (2026-09-27): `level` and the weapon fields stay P1's.
"pilots": sim.pilots.size(),
"level2": sim.player2.level if sim.player2 != null else null,
# Shop state (2026-09-27): read at run end, so a mid-run pause-menu purchase is included.
"meta_spent": meta_spent,
"meta_levels": meta_levels,
}
func report_run(sim: Sim, mode: String, decoy_type: String, region: String, tutorial_done: bool,
build_picks: Array = [], is_auto: bool = false, meta_spent: int = 0, meta_levels: String = "") -> void:
if not _enabled or _queue == null or sim == null:
return
var blacklisted := _pending_blacklisted
_pending_blacklisted = "" # report at most once per boot
_queue.push("run", run_body(sim, mode, decoy_type, region, tutorial_done, build_picks, is_auto,
meta_spent, meta_levels, blacklisted))
# Anonymous player feedback from the pause menu (2026-09-26). Signature kept for the tests that
# build it directly (test_feedback_panel / test_build_channel / test_config_stamp); send_feedback
# strips the envelope fields before queueing.
static func feedback_body(kind: String, note: String, session: String, device: String, sim: Sim, input_style: String) -> Dictionary:
var ctx := {"input": input_style}
if sim != null:
ctx["run_time"] = snappedf(sim.run_time, 0.1)
ctx["level"] = sim.player.level
ctx["kills"] = sim.kills
ctx["hp_frac"] = snappedf(sim.player.hp / maxf(sim.player.max_hp, 1.0), 0.01)
ctx["weapons"] = ",".join(sim.active_weapon_ids)
ctx["area"] = sim.current_area
return {"build": Sim_Const.BUILD, "channel": BuildChannel.current(), "channel_probe": BuildChannel.probe(),
"session": session, "dev": device, "platform": OS.get_name(),
"kind": kind, "note": note, "context": ctx, "config_id": RemoteConfig.active_id}
func send_feedback(kind: String, note: String, sim: Sim, input_style: String) -> void:
if not _enabled or _queue == null:
return
var body := feedback_body(kind, note, "", "", sim, input_style)
for k: String in ["build", "channel", "channel_probe", "session", "dev"]:
body.erase(k)
_queue.push("feedback", body)
_queue.flush_now() # the player just pressed send: don't sit on it for 10 s
  • Step 6: Move net/dash_encounter_telemetry.gd onto the queue

Replace:

const ENDPOINT := "https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/encounter"

with nothing (delete the line). Replace:

var _http: HTTPRequest
var _session: String = ""
var _device: String = ""
var _enabled: bool = false

with:

var _queue: EventQueue
var _enabled: bool = false

Replace the whole setup():

func setup() -> void:
_enabled = not OS.has_feature("editor") and DisplayServer.get_name() != "headless"
if not _enabled:
return
_session = "%08x%08x" % [randi(), randi()]
_device = TelemetryDevice.id()
_http = HTTPRequest.new()
add_child(_http)

with:

# Telemetry v2: encounters ride the EventQueue, so they carry the RUN's session (they used to have
# their own per-boot id and could not be joined to runs).
func setup(queue: EventQueue = null) -> void:
_queue = queue
_enabled = queue != null and EventQueue.live_platform()

Replace the whole _send():

func _send(enc: Dictionary) -> void:
if not _enabled or _queue == null:
return
_queue.push("encounter", {
"is_auto": false,
"config_id": RemoteConfig.active_id,
"enemy_type": enc.enemy_type, "chain": enc.chain, "outcome": enc.outcome,
"player_dashed": enc.player_dashed, "closest_approach_px": enc.closest_approach_px,
"duration_s": enc.duration_s, "samples": enc.samples,
})
  • Step 7: Wire main.gd

(a) After var dash_encounter_telemetry: DashEncounterTelemetry (~line 104), add:

var event_queue: EventQueue # telemetry v2: the one persistent pipe every reporter pushes through

(b) In _ready, directly after MetaStore.ensure_install_id(meta) (~line 318), add:

event_queue = EventQueue.new() # persistent (never swept): the telemetry v2 event pipe
add_child(event_queue)
event_queue.setup(meta.install_id, true)
# Envelope `auto`: the attract-mode AI is flying the live run (false at the menu).
event_queue.auto_fn = func() -> bool: return sim != null and _auto

(c) In _ready, change gameplay_telemetry.setup() to gameplay_telemetry.setup(event_queue), and dash_encounter_telemetry.setup() to dash_encounter_telemetry.setup(event_queue).

(d) In _setup_showcase (~line 424), add as the first line of the body:

event_queue.muted = true # showcase runs never report (telemetry v2)

(e) In the pause-menu wiring (~line 530), replace:

gameplay_telemetry.send_feedback(kind, note, _run_session, sim, ButtonPrompt.current_style()))

with:

gameplay_telemetry.send_feedback(kind, note, sim, ButtonPrompt.current_style()))

(f) In BOTH child-sweep loops, in _return_to_menu (~line 686) and _new_run (~line 835), change the first condition line:

if c == gameplay_telemetry or c == dash_encounter_telemetry or c == quality_manager or c == control_client \

to:

if c == gameplay_telemetry or c == dash_encounter_telemetry or c == event_queue or c == quality_manager or c == control_client \

(g) In _return_to_menu, directly before sim = null, add:

event_queue.set_session("") # menu-time events (menu, shop_buy, feedback) belong to no run

(h) In _new_run’s replay block (~line 883), after dash_encounter_telemetry._enabled = false, add:

event_queue.muted = true # replay/capture never reports (telemetry v2); holds for the session

(i) In _new_run, replace:

telemetry.setup(sim, _run_session)

with:

event_queue.set_session(_run_session) # every reporter's events join this run
telemetry.setup(sim, event_queue)

(j) In _bank_run, replace:

gameplay_telemetry.report_run(sim, mode, sim.decoy_type, region, meta.tutorial_done,
_run_session, _applied_upgrades, _auto,
meta.gold_spent, meta.levels_code()) # gold actually paid: stable under a price patch

with:

gameplay_telemetry.report_run(sim, mode, sim.decoy_type, region, meta.tutorial_done,
_applied_upgrades, _auto,
meta.gold_spent, meta.levels_code()) # gold actually paid: stable under a price patch
  • Step 8: Check that no caller still uses the old signatures

Run: /usr/bin/grep -rn "_run_session, sim\|report_run(.*_run_session\|telemetry.setup(sim, _run_session\|_hitch_queue\|HITCH_ENDPOINT\|FEEDBACK_ENDPOINT" --include="*.gd" . Expected: no output.

  • Step 9: Run the tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_telemetry_reporters_queue -gexit && godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_hitch_tracker -gexit && godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_dash_encounter_telemetry -gexit Expected: PASS for all three.

  • Step 10: Gate and commit

Run: bash scripts/gate.sh → == GATE PASS. The boot smoke proves main still boots with the queue wired.

Terminal window
cat > /tmp/bh-msg.txt <<'EOF'
feat(telemetry): perf, hitch, run, encounter and feedback reporters push through EventQueue
Telemetry v2 Task 3. One session id per run across every reporter (encounters used to
mint their own), install + launch ids on every event, payloads no longer repeat the
envelope. Replay/capture/showcase mute the queue.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gk
EOF
git add net/telemetry.gd net/gameplay_telemetry.gd net/dash_encounter_telemetry.gd main.gd tests/test_hitch_tracker.gd tests/test_telemetry_reporters_queue.gd
git commit -F /tmp/bh-msg.txt

Task 4: Lifecycle events, exit marker and prev_exit, run_start and run_end, shop_buy

Section titled “Task 4: Lifecycle events, exit marker and prev_exit, run_start and run_end, shop_buy”

Files:

  • Create: net/exit_marker.gd, net/lifecycle_telemetry.gd
  • Modify: net/local_json.gd (add remove)
  • Modify: ui/meta_shop_panel.gd: signal list ~line 24, and _activate ~line 1186
  • Modify: main.gd:
    • vars
    • _ready
    • _show_start_menu
    • _return_to_menu
    • _new_run (top, _run_banked = false, after _auto = ...)
    • the three _bank_run() call sites
    • _bank_run
    • _process
    • meta_shop wiring
  • Test: tests/test_exit_marker.gd, tests/test_lifecycle_telemetry.gd, tests/test_lifecycle_main.gd, tests/test_meta_shop_panel.gd (one new test)

Interfaces:

  • Consumes:

    • EventQueue.push, flush_now, launch_id, live_platform
    • Sim.tutorial: TutorialLessons (index, step, current_lesson())
  • Produces:

    • ExitMarker:
      • FILE := "exit_marker.json"
      • static classify(marker: Dictionary, is_web: bool) -> String: "" | "crash" | "killed_in_background" | "web_closed"
      • static prev_exit_payload(marker, is_web) -> Dictionary
    • LifecycleTelemetry (Node):
      • setup(queue)
      • boot(first_launch: bool, share: bool)
      • boot_with_marker(marker, first_launch, share, is_web)
      • static launch_payload(first_launch, share) -> Dictionary
      • marker_dict() -> Dictionary
      • write_marker()
      • on_menu()
      • poll_tutorial(sim: Sim)
      • on_background()
      • on_foreground()
      • var info_fn: Callable
      • var marker_writes: int
    • LocalJson.remove(name: String) -> void
    • MetaShopPanel signal purchased(id: String, cost: int, gold_after: int)
    • main:
      • _bank_run(reason := "death")
      • _send_run_end(reason: String)
      • _lifecycle_info() -> Dictionary
      • var lifecycle: LifecycleTelemetry
      • var _run_end_sent: bool
  • prev_exit.d.reason adds "web_closed" next to the spec’s two. A web tab close never delivers WM_CLOSE, so without it every web session would read as a crash.

  • Step 1: Write the failing tests tests/test_exit_marker.gd

extends GutTest
# ExitMarker (telemetry v2): how did the last launch end? The marker is rewritten while the app
# runs and removed only on a clean quit, so finding one at boot means the last launch died.
func test_no_marker_means_a_clean_last_exit() -> void:
assert_eq(ExitMarker.classify({}, false), "")
assert_true(ExitMarker.prev_exit_payload({}, false).is_empty())
func test_a_backgrounded_marker_is_a_background_kill() -> void:
assert_eq(ExitMarker.classify({"backgrounded": true}, false), "killed_in_background")
func test_a_foreground_marker_is_a_crash() -> void:
assert_eq(ExitMarker.classify({"backgrounded": false}, false), "crash")
func test_web_never_gets_a_close_notification_so_it_is_web_closed() -> void:
assert_eq(ExitMarker.classify({"backgrounded": false}, true), "web_closed")
func test_the_payload_carries_the_dead_launch_and_session() -> void:
var m := {"launch": "L0", "session": "S0", "build": 209, "run_time": 81.5, "state": "levelup",
"mode": "survival", "recent": "new:death", "backgrounded": false, "ts": 1700000000}
var p := ExitMarker.prev_exit_payload(m, false)
assert_eq(p.reason, "crash")
assert_eq(p.prev_launch, "L0")
assert_eq(p.prev_session, "S0")
assert_eq(int(p.prev_build), 209)
assert_almost_eq(float(p.run_time), 81.5, 0.001)
assert_eq(p.state, "levelup")
assert_eq(p.mode, "survival")
assert_eq(p.recent, "new:death")
assert_eq(int(p.marker_ts), 1700000000)
  • Step 2: Write the failing tests tests/test_lifecycle_telemetry.gd
extends GutTest
# LifecycleTelemetry (telemetry v2): launch / prev_exit / background / foreground / menu /
# tutorial_step events and the exit-marker contents. File IO is off headless (not live), so the
# marker is exercised through marker_dict() and boot_with_marker().
var q: EventQueue
var lc: LifecycleTelemetry
func before_each() -> void:
q = EventQueue.new()
add_child_autofree(q)
q.setup("inst", true)
q.enable_for_test()
lc = LifecycleTelemetry.new()
add_child_autofree(lc)
lc.setup(q)
lc.info_fn = func() -> Dictionary:
return {"session": "run9", "run_time": 12.5, "state": "play", "mode": "survival", "recent": "death"}
func _events(t: String) -> Array:
return q.core.pending_events().filter(func(e: Dictionary) -> bool: return e.t == t)
func test_boot_without_a_marker_sends_launch_only() -> void:
lc.boot_with_marker({}, true, true, false)
assert_eq(q.core.pending_events().map(func(e: Dictionary) -> String: return String(e.t)), ["launch"])
var d: Dictionary = _events("launch")[0].d
assert_eq(d.first_launch, true)
assert_eq(d.share_diagnostics, true)
for k in ["os", "os_version", "model", "channel_probe"]:
assert_true(d.has(k), k)
func test_boot_with_a_backgrounded_marker_adds_prev_exit() -> void:
lc.boot_with_marker({"launch": "L0", "session": "S0", "backgrounded": true}, false, true, false)
var p: Array = _events("prev_exit")
assert_eq(p.size(), 1)
assert_eq(p[0].d.reason, "killed_in_background")
assert_eq(p[0].d.prev_session, "S0")
func test_background_and_foreground_are_deduped_and_paired() -> void:
lc._notification(Node.NOTIFICATION_APPLICATION_FOCUS_OUT)
lc._notification(Node.NOTIFICATION_APPLICATION_PAUSED) # iOS sends both: one event
assert_eq(_events("background").size(), 1)
assert_eq(_events("background")[0].d.state, "play")
assert_true(bool(lc.marker_dict().backgrounded), "the marker records the app went to background")
assert_eq(q.sent_bodies.size(), 1, "backgrounding flushes straight away")
lc._notification(Node.NOTIFICATION_APPLICATION_RESUMED)
lc._notification(Node.NOTIFICATION_APPLICATION_FOCUS_IN)
assert_eq(_events("foreground").size(), 1)
assert_false(bool(lc.marker_dict().backgrounded))
func test_foreground_without_a_background_sends_nothing() -> void:
lc._notification(Node.NOTIFICATION_APPLICATION_FOCUS_IN)
assert_eq(_events("foreground").size(), 0)
func test_marker_dict_holds_what_prev_exit_needs() -> void:
var m := lc.marker_dict()
assert_eq(m.launch, q.launch_id())
assert_eq(m.session, "run9")
assert_almost_eq(float(m.run_time), 12.5, 0.001)
assert_eq(m.state, "play")
assert_eq(m.mode, "survival")
assert_eq(m.recent, "death")
assert_eq(int(m.build), Sim_Const.BUILD)
assert_false(bool(m.backgrounded))
func test_menu_event() -> void:
lc.on_menu()
assert_eq(_events("menu").size(), 1)
func test_tutorial_steps_are_sent_once_per_change() -> void:
var sim := Sim.new(1234, ContentLoader.load_from_path("res://data/bible.json"))
sim.enable_story(StoryLoader.load_from_path("res://data/story.json"), false, false, true)
assert_not_null(sim.tutorial, "sanity: the capped tutorial has a lesson track")
lc.poll_tutorial(sim)
lc.poll_tutorial(sim)
var steps: Array = _events("tutorial_step")
assert_eq(steps.size(), 1, "no change, no second event")
assert_eq(steps[0].d.lesson, "wake")
sim.tutorial.index = 1
sim.tutorial.step = 0
lc.poll_tutorial(sim)
steps = _events("tutorial_step")
assert_eq(steps.size(), 2)
assert_eq(steps[1].d.lesson, "blaster")
lc.poll_tutorial(null)
assert_eq(_events("tutorial_step").size(), 2, "no tutorial, no event")
  • Step 3: Write the failing tests tests/test_lifecycle_main.gd
extends GutTest
# main.gd wiring for telemetry v2 lifecycle events: run_start, run_end (every exit reason main can
# see), menu. Headless boot auto-starts a survival run; the queue is switched to test mode after.
func _boot() -> Node:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m.event_queue.enable_for_test()
return m
func _of(m: Node, t: String) -> Array:
return m.event_queue.core.pending_events().filter(func(e: Dictionary) -> bool: return e.t == t)
func test_quit_to_menu_sends_run_end_quit_menu_then_menu() -> void:
var m = await _boot()
m._return_to_menu()
var ends := _of(m, "run_end")
assert_eq(ends.size(), 1)
assert_eq(ends[0].d.reason, "quit_menu")
assert_true(ends[0].d.has("run_time") and ends[0].d.has("level"))
assert_eq(_of(m, "menu").size(), 1)
func test_a_death_sends_run_end_death_once_even_if_the_player_then_quits() -> void:
var m = await _boot()
m._bank_run("death")
m._return_to_menu()
var ends := _of(m, "run_end")
assert_eq(ends.size(), 1)
assert_eq(ends[0].d.reason, "death")
func test_restarting_a_live_run_sends_dev_restart_then_run_start() -> void:
var m = await _boot()
m._on_mode_chosen("survival")
var ends := _of(m, "run_end")
assert_eq(ends.size(), 1)
assert_eq(ends[0].d.reason, "dev_restart")
var starts := _of(m, "run_start")
assert_eq(starts.size(), 1)
assert_eq(starts[0].d.mode, "survival")
assert_eq(starts[0].d.ruleset, "survival")
assert_true(starts[0].d.has("meta_spent") and starts[0].d.has("banked_gold") and starts[0].d.has("ship"))
assert_eq(starts[0].session, m._run_session, "run_start carries the new run's session")
  • Step 4: Add the shop signal test to tests/test_meta_shop_panel.gd (append at the end)
# Telemetry v2 shop_buy: a purchase reports what it cost and the gold left.
func test_a_purchase_emits_purchased_with_price_and_gold_after() -> void:
var meta := MetaState.new()
meta.banked_gold = 100000
var p: MetaShopPanel = await _open(meta)
var price := meta.cost(_def(_defs(), "vitality"))
watch_signals(p)
p._activate("vitality")
assert_signal_emitted_with_parameters(p, "purchased", ["vitality", price, 100000 - price])
p.hide_panel()
  • Step 5: Run the tests to verify they fail

Run: godot --headless --path . --import; for t in test_exit_marker test_lifecycle_telemetry test_lifecycle_main test_meta_shop_panel; do godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=$t -gexit; done Expected:

  • FAIL on unknown classes ExitMarker/LifecycleTelemetry, event_queue pushes with no run_end, and a missing purchased signal.

  • The pre-existing test_meta_shop_panel tests still pass.

  • Step 6: Add LocalJson.remove to net/local_json.gd (append)

static func remove(name: String) -> void:
if not io_enabled:
return
for p in _paths(name):
if FileAccess.file_exists(p):
DirAccess.remove_absolute(ProjectSettings.globalize_path(p))
  • Step 7: Create net/exit_marker.gd
class_name ExitMarker
extends RefCounted
# Telemetry v2: how did the LAST launch end? LifecycleTelemetry rewrites exit_marker.json while
# the app runs (every 5 s, on run start, on background/foreground) and removes it on a clean quit
# (WM_CLOSE_REQUEST). Finding it at boot means the last launch died. iOS/tvOS apps are normally
# killed in the background (never a WM_CLOSE), which is why `backgrounded` is recorded.
# Pure: no file IO here. Separate from RemoteConfigState's crash marker (that one drives
# config blacklisting and only arms for non-default configs).
const FILE := "exit_marker.json"
static func classify(marker: Dictionary, is_web: bool) -> String:
if marker.is_empty():
return ""
if is_web:
return "web_closed" # a tab close never delivers WM_CLOSE: not evidence of a crash
if bool(marker.get("backgrounded", false)):
return "killed_in_background"
return "crash" # died in the foreground: crash, watchdog or OOM
static func prev_exit_payload(marker: Dictionary, is_web: bool) -> Dictionary:
var reason := classify(marker, is_web)
if reason == "":
return {}
return {"reason": reason, "prev_launch": String(marker.get("launch", "")),
"prev_session": String(marker.get("session", "")), "prev_build": int(marker.get("build", 0)),
"run_time": float(marker.get("run_time", 0.0)), "state": String(marker.get("state", "")),
"mode": String(marker.get("mode", "")), "recent": String(marker.get("recent", "")),
"marker_ts": int(marker.get("ts", 0))}
  • Step 8: Create net/lifecycle_telemetry.gd
class_name LifecycleTelemetry
extends Node
# Telemetry v2 lifecycle reporter (render-side, persistent). It sends:
# - launch, and prev_exit when the last launch died (ExitMarker)
# - background / foreground (deduped: iOS sends FOCUS_OUT and PAUSED together)
# - menu, and tutorial_step on each lesson/step change (a render-side poll: no sim change)
# It also keeps the exit marker current. main supplies what the marker records via `info_fn`.
# File IO only when live (not editor/headless), so GUT never touches the real marker.
const MARKER_S := 5.0
var info_fn: Callable # main._lifecycle_info: {session, run_time, state, mode, recent}
var marker_writes: int = 0 # diagnostic / test seam
var _queue: EventQueue
var _live: bool = false
var _accum: float = 0.0
var _backgrounded: bool = false
var _tutorial_key: String = ""
func setup(queue: EventQueue) -> void:
_queue = queue
_live = EventQueue.live_platform()
func _push(t: String, d: Dictionary) -> void:
if _queue != null:
_queue.push(t, d)
func boot(first_launch: bool, share: bool) -> void:
var marker: Dictionary = {}
if _live:
marker = LocalJson.read(ExitMarker.FILE)
boot_with_marker(marker, first_launch, share, OS.has_feature("web"))
write_marker()
func boot_with_marker(marker: Dictionary, first_launch: bool, share: bool, is_web: bool) -> void:
_push("launch", launch_payload(first_launch, share))
var prev := ExitMarker.prev_exit_payload(marker, is_web)
if not prev.is_empty():
_push("prev_exit", prev)
# Locale-free by design. channel_probe rides here once per launch instead of on every event.
static func launch_payload(first_launch: bool, share: bool) -> Dictionary:
return {"os": OS.get_name(), "os_version": OS.get_version(), "model": OS.get_model_name(),
"first_launch": first_launch, "share_diagnostics": share, "channel_probe": BuildChannel.probe()}
func _info() -> Dictionary:
if info_fn.is_valid():
var v: Variant = info_fn.call()
if v is Dictionary:
return v
return {}
func marker_dict() -> Dictionary:
var info := _info()
return {"launch": _queue.launch_id() if _queue != null else "",
"session": String(info.get("session", "")), "run_time": float(info.get("run_time", 0.0)),
"state": String(info.get("state", "")), "mode": String(info.get("mode", "")),
"recent": String(info.get("recent", "")), "build": Sim_Const.BUILD,
"backgrounded": _backgrounded, "ts": int(Time.get_unix_time_from_system())}
func write_marker() -> void:
marker_writes += 1
if _live:
LocalJson.write(ExitMarker.FILE, marker_dict())
func _process(dt: float) -> void:
_accum += dt
if _accum >= MARKER_S:
_accum = 0.0
write_marker()
func on_menu() -> void:
_push("menu", {})
func poll_tutorial(sim: Sim) -> void:
if sim == null or sim.tutorial == null:
_tutorial_key = ""
return
var t: TutorialLessons = sim.tutorial
var key := "%d:%d" % [t.index, t.step]
if key == _tutorial_key:
return
_tutorial_key = key
_push("tutorial_step", {"lesson": String(t.current_lesson().get("id", "done")),
"lesson_i": t.index, "step": t.step, "run_time": snappedf(sim.run_time, 0.1)})
func on_background() -> void:
if _backgrounded:
return
_backgrounded = true
var info := _info()
_push("background", {"state": String(info.get("state", "")), "run_time": float(info.get("run_time", 0.0))})
write_marker()
if _queue != null:
_queue.flush_now() # the process may never come back
func on_foreground() -> void:
if not _backgrounded:
return
_backgrounded = false
var info := _info()
_push("foreground", {"state": String(info.get("state", "")), "run_time": float(info.get("run_time", 0.0))})
write_marker()
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_PAUSED or what == NOTIFICATION_APPLICATION_FOCUS_OUT:
on_background()
elif what == NOTIFICATION_APPLICATION_RESUMED or what == NOTIFICATION_APPLICATION_FOCUS_IN:
on_foreground()
elif what == NOTIFICATION_WM_CLOSE_REQUEST:
if _live:
LocalJson.remove(ExitMarker.FILE) # a clean quit: no prev_exit next boot
  • Step 9: Emit purchased from ui/meta_shop_panel.gd

After signal bay_requested ... (line 24), add:

signal purchased(id: String, cost: int, gold_after: int) # telemetry v2 shop_buy

In _activate, replace:

if _meta.buy(def):
var nudge := _after_drone_buy(def) # may auto-equip a new class: before the save

with:

var price := _meta.cost(def) # read before buy(): buying raises the level and the price
if _meta.buy(def):
purchased.emit(id, price, _meta.banked_gold)
var nudge := _after_drone_buy(def) # may auto-equip a new class: before the save
  • Step 10: Wire main.gd

(a) Vars, after var event_queue: EventQueue:

var lifecycle: LifecycleTelemetry # telemetry v2: launch/prev_exit/background/menu/tutorial_step + exit marker
var _run_end_sent := false # this run already reported its run_end (one per run)

(b) In _ready, replace:

meta = MetaStore.load_state() # persistent progression, survives _new_run()
MetaStore.ensure_install_id(meta) # never writes over a save this build failed to read

with:

meta = MetaStore.load_state() # persistent progression, survives _new_run()
# First launch = nothing on this device yet. Read before ensure_install_id mints the id.
var first_launch := meta.install_id == "" and meta.banked_gold == 0 and meta.gold_spent == 0 \
and not meta.tutorial_done
MetaStore.ensure_install_id(meta) # never writes over a save this build failed to read

After the event_queue.auto_fn = ... line from Task 3, add:

lifecycle = LifecycleTelemetry.new() # persistent: launch / prev_exit / lifecycle events
add_child(lifecycle)
lifecycle.setup(event_queue)
lifecycle.info_fn = _lifecycle_info
lifecycle.boot(first_launch, true)

(c) In _ready, right after meta_shop = MetaShopPanel.new() (~line 370), add:

meta_shop.purchased.connect(func(id: String, cost: int, gold_after: int) -> void:
event_queue.push("shop_buy", {"id": id, "cost": cost, "gold_after": gold_after,
"level": meta.level_of(id), "in_run": sim != null}))

(d) Both child-sweep conditions (from Task 3 (f)) gain or c == lifecycle after c == event_queue.

(e) In _show_start_menu, directly after the perf_bench/auto_showcase early return block, add:

if lifecycle != null:
lifecycle.on_menu()

(f) In _return_to_menu, as the first line of the body:

_send_run_end("quit_menu") # no-op if this run already ended (death / story / tutorial cap)

(g) In _new_run, as the first line of the body (before the child sweep; sim is still the old run):

_send_run_end("dev_restart") # a live run replaced without ending (dev tools / tests); no-op after a real end

and next to _run_banked = false, add:

_run_end_sent = false

(h) In _new_run, directly after _auto = not _is_story() (~line 1289), add:

event_queue.push("run_start", {"mode": _mode,
"ruleset": "survival" if sim.ruleset == Sim.RULESET_SURVIVAL else "reactions",
"meta_spent": meta.gold_spent if meta != null else 0,
"banked_gold": meta.banked_gold if meta != null else 0,
"meta_levels": meta.levels_code() if meta != null else "",
"ship": meta.selected_ship if meta != null else ""})
lifecycle.write_marker() # the marker now names this run

(i) The three _bank_run() call sites in _physics_process become:

  • tutorial cap (~line 1499): _bank_run("tutorial_cap")
  • story complete (~line 1516): _bank_run("story_complete")
  • game over (~line 1539): _bank_run("death")

(j) Change _bank_run’s signature and add the run_end at the end of its body:

func _bank_run(reason: String = "death") -> void:

Directly after the gameplay_telemetry.report_run(...) call, add:

_send_run_end(reason)

(k) Add these two functions after _bank_run:

# Telemetry v2: exactly one run_end per run, with why it ended. A banked run also writes a `runs`
# row (report_run); quit_menu / dev_restart write only this event (runs stays banked-runs-only).
func _send_run_end(reason: String) -> void:
if _run_end_sent or sim == null:
return
_run_end_sent = true
event_queue.push("run_end", {"reason": reason, "mode": _mode, "run_time": snappedf(sim.run_time, 0.1),
"level": sim.player.level, "kills": sim.kills})
event_queue.flush_now()
# What the exit marker records (LifecycleTelemetry.info_fn).
func _lifecycle_info() -> Dictionary:
var recent := ""
if sim != null and is_instance_valid(telemetry):
recent = telemetry.hitches.recent_tags()
return {"session": _run_session if sim != null else "",
"run_time": sim.run_time if sim != null else 0.0,
"state": _telemetry_state(), "mode": _mode if sim != null else "", "recent": recent}

(l) In _process, directly BEFORE if sim == null: / return # start menu is up; no run yet, add:

if lifecycle != null:
lifecycle.poll_tutorial(sim) # telemetry v2 tutorial_step (null-safe: resets at the menu)
  • Step 11: Run the tests to verify they pass

Run: godot --headless --path . --import; for t in test_exit_marker test_lifecycle_telemetry test_lifecycle_main test_meta_shop_panel test_main; do godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=$t -gexit || break; done Expected: PASS for all five. test_main is included because _bank_run/_new_run/_return_to_menu changed.

  • Step 12: Gate and commit

Run: bash scripts/gate.sh → == GATE PASS. The tutorial trace must still print lessons: ok; poll_tutorial only reads.

Terminal window
cat > /tmp/bh-msg.txt <<'EOF'
feat(telemetry): lifecycle events, exit marker + prev_exit, run_start/run_end reasons, shop_buy
Telemetry v2 Task 4. launch (+ prev_exit when the last launch died: crash vs
killed_in_background vs web_closed), background/foreground, menu, tutorial_step,
run_start, run_end{death|story_complete|tutorial_cap|quit_menu|dev_restart}, shop_buy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gk
EOF
git add net/exit_marker.gd net/lifecycle_telemetry.gd net/local_json.gd ui/meta_shop_panel.gd main.gd tests/test_exit_marker.gd tests/test_lifecycle_telemetry.gd tests/test_lifecycle_main.gd tests/test_meta_shop_panel.gd
git commit -F /tmp/bh-msg.txt

Task 5: Run timeline (XP by enemy type, boss log, level-ups offered and picked, damage by weapon)

Section titled “Task 5: Run timeline (XP by enemy type, boss log, level-ups offered and picked, damage by weapon)”

Files:

  • Modify: sim/sim.gd:
    • fields after var damage_by_weapon (~line 355)
    • spawn_story_boss (~line 1038)
    • _award_xp (~line 2541)
    • _sweep_dead (~lines 2590-2625)
  • Modify: sim/boss_warden.gd:85, sim/boss2.gd:50, sim/funzo.gd:91, sim/graviton.gd:61, sim/eye.gd:68
  • Create: net/run_timeline.gd
  • Modify: net/gameplay_telemetry.gd (report_run gains timeline)
  • Modify: main.gd:
    • vars
    • _new_run (_applied_upgrades = [], ~line 1329)
    • _open_levelup (~line 1960)
    • _on_upgrade_chosen (~line 2605)
    • _bank_run
  • Test: tests/test_run_timeline_sim.gd, tests/test_run_timeline.gd

Interfaces:

  • Consumes:

    • GameplayTelemetry.report_run from Task 3
    • EnemyPool.type_name(t: int) -> String
    • Sim.damage_by_weapon
  • Produces:

    • Sim:
      • Sim.xp_by_type: Dictionary (type_id → float XP actually awarded)
      • Sim.boss_log: Array ([type_id: int, spawn_t: float, kill_t: float], kill_t = -1.0 while alive)
      • Sim.note_boss_spawn(type_id: int) -> void
      • Sim._award_xp(...) -> float (was -> void)
    • RunTimeline:
      • RunTimeline.note_offered(run_time: float, level: int, ids: Array) -> void
      • RunTimeline.note_picked(id: String) -> void
      • RunTimeline.to_dict(sim: Sim) -> Dictionary (keys levelups, xp_by_type, damage_by_weapon, bosses)
    • GameplayTelemetry.report_run(..., meta_levels := "", timeline: Dictionary = {}) → d.timeline
  • Determinism:

    • None of the new sim fields are read by the sim or touched by state_checksum()/snapshot_string().
    • _award_xp only gains a return value.
    • No RNG draw and no ordering change.
  • Note: spawn_story_boss does NOT bump _boss_spawn_count (only the five survival directors do), so it gets its own note_boss_spawn call.

  • Step 1: Write the failing sim tests tests/test_run_timeline_sim.gd

extends GutTest
# Telemetry v2 run timeline, sim half: xp_by_type and boss_log are filled through the REAL
# spawn -> damage -> _sweep_dead path (a hand-set counter would prove nothing -- see
# memory bullet-heaven-story-mode-xp-was-zero), and they never feed the checksum.
func test_xp_by_type_records_the_xp_a_real_kill_awarded() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
sim._spawn_one(EnemyPool.TYPE_SWARMER, Vector2(200, 0)) # run_time 0 < ELITE_START: never elite
var i := sim.enemies.count - 1
assert_eq(sim.enemies.type_id[i], EnemyPool.TYPE_SWARMER)
var expected := sim._xp_worth(i) * sim.area_reward_mult * sim._xp_depth_mult()
assert_gt(expected, 0.0, "sanity: the spawn path set biomass")
sim.projectiles.add_proj(sim.enemies.pos[i], Vector2.ZERO, 6.0, 1.0e9, 1.0)
sim.elemental_system.resolve_collisions(sim)
sim._sweep_dead()
assert_eq(sim.kills_by_type.get(EnemyPool.TYPE_SWARMER, 0), 1, "sanity: it died through the sweep")
assert_almost_eq(float(sim.xp_by_type.get(EnemyPool.TYPE_SWARMER, 0.0)), expected, 0.001)
func test_boss_log_records_spawn_and_kill_times() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.run_time = 30.0
sim.boss_warden.spawn(sim)
assert_eq(sim.boss_log.size(), 1)
assert_eq(int(sim.boss_log[0][0]), EnemyPool.TYPE_BOSS)
assert_almost_eq(float(sim.boss_log[0][1]), 30.0, 0.001)
assert_lt(float(sim.boss_log[0][2]), 0.0, "alive: no kill time yet")
var bi := -1
for k in sim.enemies.count:
if sim.enemies.type_id[k] == EnemyPool.TYPE_BOSS:
bi = k
assert_gte(bi, 0, "sanity: the Warden is in the pool")
sim.run_time = 72.5
sim.elemental_system.damage_enemy(sim, bi, 1.0e12, true)
sim._sweep_dead()
assert_almost_eq(float(sim.boss_log[0][2]), 72.5, 0.001)
assert_gt(float(sim.xp_by_type.get(EnemyPool.TYPE_BOSS, 0.0)), 0.0)
func test_every_survival_boss_director_logs_its_spawn() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.boss2_director.spawn(sim, Vector2(300, 0))
sim.funzo_director.spawn(sim, Vector2(-300, 0))
sim.graviton_director.spawn(sim, Vector2(0, 300))
sim.eye_director.spawn(sim, Vector2(0, -300))
var types := sim.boss_log.map(func(b: Array) -> int: return int(b[0]))
assert_eq(types, [EnemyPool.TYPE_BOSS2, EnemyPool.TYPE_FUNZO, EnemyPool.TYPE_GRAVITON, EnemyPool.TYPE_EYE])
func test_a_story_boss_logs_its_spawn() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.spawn_story_boss(Vector2(250, 0), 0.25)
assert_eq(sim.boss_log.size(), 1)
assert_eq(int(sim.boss_log[0][0]), EnemyPool.TYPE_BOSS)
func test_timeline_counters_never_feed_the_checksum() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
for k in 120:
sim.tick_single(InputState.new())
var before := sim.state_checksum()
var snap := sim.snapshot_string()
sim.xp_by_type[EnemyPool.TYPE_SWARMER] = 999.0
sim.boss_log.append([EnemyPool.TYPE_BOSS, 1.0, 2.0])
assert_eq(sim.state_checksum(), before)
assert_eq(sim.snapshot_string(), snap)
  • Step 2: Write the failing timeline tests tests/test_run_timeline.gd
extends GutTest
# RunTimeline (render-side): level-ups offered vs picked, and the run-end timeline dict that goes
# out in the "run" event (runs.timeline_json).
func test_offered_then_picked_is_one_entry() -> void:
var tl := RunTimeline.new()
tl.note_offered(12.34, 2, ["a", "b", "c"])
tl.note_picked("b")
assert_eq(tl.levelups, [[12.3, 2, ["a", "b", "c"], "b"]])
func test_a_pick_without_an_offer_is_ignored_and_a_second_pick_does_not_overwrite() -> void:
var tl := RunTimeline.new()
tl.note_picked("x")
assert_eq(tl.levelups.size(), 0)
tl.note_offered(1.0, 2, ["a"])
tl.note_picked("a")
tl.note_picked("z")
assert_eq(tl.levelups[0][3], "a")
func test_levelups_are_capped() -> void:
var tl := RunTimeline.new()
for k in RunTimeline.MAX_LEVELUPS + 10:
tl.note_offered(float(k), k, ["a"])
assert_eq(tl.levelups.size(), RunTimeline.MAX_LEVELUPS)
func test_to_dict_names_types_and_rounds() -> void:
var sim := Sim.new(1, SimContentFixture.db())
sim.xp_by_type = {EnemyPool.TYPE_SWARMER: 12.345}
sim.damage_by_weapon = {"blade": 901.6}
sim.boss_log = [[EnemyPool.TYPE_BOSS, 80.04, 141.26], [EnemyPool.TYPE_EYE, 300.0, -1.0]]
var tl := RunTimeline.new()
tl.note_offered(5.0, 2, ["a", "b"])
tl.note_picked("a")
var d := tl.to_dict(sim)
assert_eq(d.levelups, [[5.0, 2, ["a", "b"], "a"]])
assert_eq(d.xp_by_type, {EnemyPool.type_name(EnemyPool.TYPE_SWARMER): 12.3})
assert_eq(d.damage_by_weapon, {"blade": 902.0})
assert_eq(d.bosses, [[EnemyPool.type_name(EnemyPool.TYPE_BOSS), 80.0, 141.3],
[EnemyPool.type_name(EnemyPool.TYPE_EYE), 300.0, null]])
func test_the_run_event_carries_the_timeline() -> void:
var q := EventQueue.new()
add_child_autofree(q)
q.setup("inst", true)
q.enable_for_test()
var g := GameplayTelemetry.new()
add_child_autofree(g)
g.setup(q)
g._enabled = true
g.report_run(Sim.new(1, SimContentFixture.db()), "survival", "basic", "", false, [], false, 0, "",
{"levelups": [[1.0, 2, ["a"], "a"]]})
var e: Dictionary = q.core.pending_events()[0]
assert_eq(e.d.timeline.levelups.size(), 1)
func test_main_records_the_offer_and_the_pick() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("survival")
m.sim.player.pending_levelups = 1
m._physics_process(1.0 / 60.0)
assert_true(m._paused_for_levelup, "sanity: the panel opened")
var offered: Array = m._current_choice_ids.duplicate()
m._on_upgrade_chosen(offered[0])
assert_eq(m._timeline.levelups.size(), 1)
assert_eq(m._timeline.levelups[0][2], offered)
assert_eq(m._timeline.levelups[0][3], offered[0])
  • Step 3: Run the tests to verify they fail

Run: godot --headless --path . --import; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_run_timeline -gexit Expected: FAIL (xp_by_type/boss_log/RunTimeline not declared). -gselect=test_run_timeline matches both files.

  • Step 4: Add the sim counters in sim/sim.gd

After var damage_by_weapon: Dictionary = {} (~line 355), add:

# Telemetry v2 run timeline (NOT checksummed, never read by the sim): the XP actually awarded per
# enemy type (after area/depth multipliers; written in _sweep_dead), and one
# [type_id, spawn_t, kill_t] per boss spawn (kill_t = -1.0 while alive; written by the boss spawn
# functions and _sweep_dead). Same class as kills_by_type.
var xp_by_type: Dictionary = {} # type_id -> float
var boss_log: Array = [] # [[type_id: int, spawn_t: float, kill_t: float]]
func note_boss_spawn(type_id: int) -> void:
boss_log.append([type_id, run_time, -1.0])
func _note_boss_kill(type_id: int) -> void:
for k in range(boss_log.size() - 1, -1, -1):
var e: Array = boss_log[k]
if int(e[0]) == type_id and float(e[2]) < 0.0:
e[2] = run_time
return
  • Step 5: Make _award_xp return what it awarded

Replace:

func _award_xp(amount: float, pos: Vector2) -> void:
if amount <= 0.0:
return
var v := amount * area_reward_mult * _xp_depth_mult()
if AUTO_COLLECT_XP:
for pilot in pilots:
if pilot.hp > 0.0:
_bank_xp(pilot, v)
else:
gems.add(pos, Vector2.ZERO, GEM_RADIUS, v)

with:

func _award_xp(amount: float, pos: Vector2) -> float:
if amount <= 0.0:
return 0.0
var v := amount * area_reward_mult * _xp_depth_mult()
if AUTO_COLLECT_XP:
for pilot in pilots:
if pilot.hp > 0.0:
_bank_xp(pilot, v)
else:
gems.add(pos, Vector2.ZERO, GEM_RADIUS, v)
return v # telemetry v2 xp_by_type (once per kill, not per pilot)

Then run /usr/bin/grep -n "_award_xp(" sim/*.gd. Expected: the definition, plus exactly one call in _sweep_dead. If there are more calls, they keep compiling, because a discarded return value is legal.

  • Step 6: Record XP and boss deaths in _sweep_dead

Replace:

if dead_type in [EnemyPool.TYPE_BOSS, EnemyPool.TYPE_BOSS2, EnemyPool.TYPE_FUNZO,
EnemyPool.TYPE_GRAVITON, EnemyPool.TYPE_EYE]:
boss_died = true # dimension-choice popup rolls after the sweep

with:

if dead_type in [EnemyPool.TYPE_BOSS, EnemyPool.TYPE_BOSS2, EnemyPool.TYPE_FUNZO,
EnemyPool.TYPE_GRAVITON, EnemyPool.TYPE_EYE]:
boss_died = true # dimension-choice popup rolls after the sweep
_note_boss_kill(dead_type) # telemetry v2 boss time-to-kill

Replace:

_award_xp(xp_amount, dead_pos)

with:

var got := _award_xp(xp_amount, dead_pos)
xp_by_type[dead_type] = float(xp_by_type.get(dead_type, 0.0)) + got # telemetry v2
  • Step 7: Log boss spawns

In each file, add ONE line directly after its sim._boss_spawn_count += 1:

File (line) Line to add
sim/boss_warden.gd (85) sim.note_boss_spawn(EnemyPool.TYPE_BOSS) # telemetry v2 boss_log
sim/boss2.gd (50) sim.note_boss_spawn(EnemyPool.TYPE_BOSS2) # telemetry v2 boss_log
sim/funzo.gd (91) sim.note_boss_spawn(EnemyPool.TYPE_FUNZO) # telemetry v2 boss_log
sim/graviton.gd (61) sim.note_boss_spawn(EnemyPool.TYPE_GRAVITON) # telemetry v2 boss_log
sim/eye.gd (68) sim.note_boss_spawn(EnemyPool.TYPE_EYE) # telemetry v2 boss_log

The indentation must match the _boss_spawn_count line above it. Check with /usr/bin/grep -n -A1 "_boss_spawn_count += 1" sim/boss_warden.gd sim/boss2.gd sim/funzo.gd sim/graviton.gd sim/eye.gd.

In sim/sim.gd spawn_story_boss, after boss.max_hp = hp ..., add:

note_boss_spawn(EnemyPool.TYPE_BOSS) # telemetry v2 (story Wardens don't bump _boss_spawn_count)
  • Step 8: Create net/run_timeline.gd
class_name RunTimeline
extends RefCounted
# Telemetry v2 run timeline (render-side). main records each level-up panel: when, at what level,
# the ids offered, the id picked (including autopilot picks, which go through _on_upgrade_chosen).
# to_dict() adds the sim's counters and becomes `timeline` in the "run" event -> runs.timeline_json.
const MAX_LEVELUPS := 200 # keeps the JSON well under the Worker's 60000-char timeline cap
var levelups: Array = [] # [run_time, level, [offered ids], picked id ("" until picked)]
func note_offered(run_time: float, level: int, ids: Array) -> void:
if levelups.size() >= MAX_LEVELUPS:
return
levelups.append([snappedf(run_time, 0.1), level, ids.duplicate(), ""])
func note_picked(id: String) -> void:
if levelups.is_empty():
return
var last: Array = levelups[-1]
if String(last[3]) == "":
last[3] = id
func to_dict(sim: Sim) -> Dictionary:
var xp := {}
for k in sim.xp_by_type:
var n := EnemyPool.type_name(int(k))
xp[n] = snappedf(float(xp.get(n, 0.0)) + float(sim.xp_by_type[k]), 0.1)
var dmg := {}
for k in sim.damage_by_weapon:
dmg[str(k)] = roundf(float(sim.damage_by_weapon[k]))
var bosses := []
for b: Array in sim.boss_log:
var kill: Variant = null # still alive at run end
if float(b[2]) >= 0.0:
kill = snappedf(float(b[2]), 0.1)
bosses.append([EnemyPool.type_name(int(b[0])), snappedf(float(b[1]), 0.1), kill])
return {"levelups": levelups.duplicate(true), "xp_by_type": xp, "damage_by_weapon": dmg, "bosses": bosses}
  • Step 9: Send it with the run

In net/gameplay_telemetry.gd, change the report_run signature and body to:

func report_run(sim: Sim, mode: String, decoy_type: String, region: String, tutorial_done: bool,
build_picks: Array = [], is_auto: bool = false, meta_spent: int = 0, meta_levels: String = "",
timeline: Dictionary = {}) -> void:
if not _enabled or _queue == null or sim == null:
return
var blacklisted := _pending_blacklisted
_pending_blacklisted = "" # report at most once per boot
var body := run_body(sim, mode, decoy_type, region, tutorial_done, build_picks, is_auto,
meta_spent, meta_levels, blacklisted)
if not timeline.is_empty():
body["timeline"] = timeline # telemetry v2 -> runs.timeline_json
_queue.push("run", body)

In main.gd:

(a) Add a var after var _run_end_sent := false:

var _timeline := RunTimeline.new() # telemetry v2: this run's level-ups offered/picked

(b) In _new_run, next to _applied_upgrades = [] (~line 1329), add:

_timeline = RunTimeline.new()

(c) In _open_levelup, directly after _current_choice_ids = ids, add:

_timeline.note_offered(sim.run_time, _leveling_pilot.level if _leveling_pilot != null else sim.player.level, ids)

(d) In _on_upgrade_chosen, directly after _applied_upgrades.append(id), add:

_timeline.note_picked(id)

(e) In _bank_run, change the report call to pass the timeline:

gameplay_telemetry.report_run(sim, mode, sim.decoy_type, region, meta.tutorial_done,
_applied_upgrades, _auto,
meta.gold_spent, meta.levels_code(), # gold actually paid: stable under a price patch
_timeline.to_dict(sim))
  • Step 10: Run the tests to verify they pass

Run: godot --headless --path . --import; for t in test_run_timeline test_determinism_survival test_collision_damage test_wyrm test_main; do godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=$t -gexit || break; done Expected: PASS for all of them. test_determinism_survival passing with its pinned hash is the proof that the baseline did not move.

  • Step 11: Gate and commit

Run: bash scripts/gate.sh → == GATE PASS.

Terminal window
cat > /tmp/bh-msg.txt <<'EOF'
feat(telemetry): run timeline — XP by enemy type, boss spawn/kill log, level-ups offered vs picked
Telemetry v2 Task 5. Sim gains xp_by_type + boss_log (counters outside the checksum, no rng,
determinism baseline unchanged). damage_by_weapon now sent. All of it rides the run event
as `timeline` -> runs.timeline_json.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gk
EOF
git add sim/sim.gd sim/boss_warden.gd sim/boss2.gd sim/funzo.gd sim/graviton.gd sim/eye.gd net/run_timeline.gd net/gameplay_telemetry.gd main.gd tests/test_run_timeline_sim.gd tests/test_run_timeline.gd
git commit -F /tmp/bh-msg.txt

Task 6: Perf — GPU and render-CPU time, a hitch sampler instead of “first 40”, honest labels

Section titled “Task 6: Perf — GPU and render-CPU time, a hitch sampler instead of “first 40”, honest labels”

Files:

  • Create: net/hitch_sampler.gd
  • Modify: net/hitch_tracker.gd (remove the per-session cap)
  • Modify (full rewrite shown): net/telemetry.gd
  • Modify: main.gd (flush held hitches before the session changes)
  • Modify: telemetry/src/worker.js (dashboard perf query, row and labels)
  • Modify: telemetry/schema.sql (the hitches comment)
  • Modify: tests/test_hitch_tracker.gd (delete test_session_cap, lines 79-82)
  • Test: tests/test_hitch_sampler.gd, tests/test_telemetry_reporters_queue.gd (one new test)

Interfaces:

  • Consumes: Telemetry from Task 3.

  • Produces:

    • HitchSampler:
      • const WINDOW_S := 30.0, WORST_PER_WINDOW := 2, MAX_PER_RUN := 200
      • static must_send(h: Dictionary) -> bool
      • offer(h: Dictionary, now_s: float) -> Array[Dictionary]
      • poll(now_s: float) -> Array[Dictionary]
      • drain() -> Array[Dictionary]
      • var sent: int
    • Telemetry:
      • static Telemetry.render_times(vp: RID) -> Dictionary ({gpu_ms, render_cpu_ms}, values null when ≤ 0)
      • Telemetry.flush_held() -> void
  • HitchTracker.MAX_PER_SESSION, HitchTracker.sent and HitchTracker.can_send() are deleted. Before deleting, check with /usr/bin/grep -rn "can_send\|MAX_PER_SESSION\|hitches.sent" --include="*.gd" .. After this task it must return nothing.

  • render_ms/proc_ms keep their names and values (both are Performance.TIME_PROCESS, the whole frame’s process time). Only the labels change.

  • Units: RenderingServer.viewport_get_measured_render_time_gpu/cpu and get_frame_setup_time_cpu return milliseconds [Likely, per the Godot 4 docs]. The units are confirmed on one device sample in Chris-gated step G10 (sanity: render_cpu_ms should land in roughly 0.5-20 on the ATV). Correction (Task 6 review, 2026-09-29): gpu_ms is always null on tvOS/iOS and web — the Metal driver in the godot-tvos fork has no timestamp queries (drivers/metal/rendering_device_driver_metal.cpp, timestamp_query_pool_get_results zeroes the buffer) and GLES only queries timestamps on desktop GL. gpu_ms is kept (free; fills on desktop Vulkan/D3D12/GL). The render-cost metric on our platforms is render_cpu_ms, summed over the root viewport and every UPDATE_ALWAYS SubViewport (nebula backdrop, 3D ship); as shipped, render_times takes Array[RID] and render_times_from(gpu, viewports_cpu, setup_cpu) is the pure null rule.

  • Step 1: Write the failing sampler tests tests/test_hitch_sampler.gd

extends GutTest
# HitchSampler (telemetry v2): first-use / pipeline-compile hitches always go; ordinary ones only
# the worst 2 per 30 s window; never more than 200 per run.
func _h(ms: float, recent := "", pipelines := 0) -> Dictionary:
return {"frame_ms": ms, "recent": recent, "pipelines": pipelines}
func test_must_send_is_first_use_or_a_pipeline_compile() -> void:
assert_true(HitchSampler.must_send(_h(70.0, "death,new:reaction:WARDEN")))
assert_true(HitchSampler.must_send(_h(70.0, "", 2)))
assert_false(HitchSampler.must_send(_h(300.0, "death,state:play")))
func test_a_must_send_hitch_goes_immediately() -> void:
var s := HitchSampler.new()
var out := s.offer(_h(80.0, "new:death"), 0.0)
assert_eq(out.size(), 1)
assert_eq(s.sent, 1)
func test_ordinary_hitches_keep_the_worst_two_until_the_window_closes() -> void:
var s := HitchSampler.new()
assert_eq(s.offer(_h(70.0), 0.0).size(), 0)
assert_eq(s.offer(_h(200.0), 5.0).size(), 0)
assert_eq(s.offer(_h(90.0), 10.0).size(), 0)
assert_eq(s.offer(_h(150.0), 20.0).size(), 0)
assert_eq(s.poll(29.9).size(), 0, "window still open")
var out := s.poll(30.0)
assert_eq(out.map(func(h: Dictionary) -> float: return float(h.frame_ms)), [200.0, 150.0])
assert_eq(s.poll(31.0).size(), 0, "a new window starts empty")
func test_drain_hands_back_what_is_held() -> void:
var s := HitchSampler.new()
s.offer(_h(70.0), 0.0)
assert_eq(s.drain().size(), 1)
assert_eq(s.drain().size(), 0)
func test_never_more_than_200_per_run() -> void:
var s := HitchSampler.new()
var total := 0
for k in 250:
total += s.offer(_h(80.0, "new:x%d" % k), float(k)).size()
assert_eq(total, HitchSampler.MAX_PER_RUN)
assert_eq(s.sent, HitchSampler.MAX_PER_RUN)

Append to tests/test_telemetry_reporters_queue.gd:

func test_a_sample_carries_gpu_and_render_cpu_keys() -> void:
var q := _queue()
var t := Telemetry.new()
add_child_autofree(t)
t.setup(_sim(), q)
t._enabled = true
t._send_sample()
var e := _only(q)
assert_true(e.d.has("gpu_ms"), "present (null when the renderer can't measure, e.g. headless)")
assert_true(e.d.has("render_cpu_ms"))
func test_render_times_turn_zero_into_null() -> void:
# Headless never measures, so both read 0 and must go out as null, not 0.
var rt := Telemetry.render_times(get_viewport().get_viewport_rid())
assert_null(rt.gpu_ms)
assert_null(rt.render_cpu_ms)
  • Step 2: Run the tests to verify they fail

Run: godot --headless --path . --import; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_hitch_sampler -gexit Expected: FAIL (HitchSampler not declared).

  • Step 3: Create net/hitch_sampler.gd
class_name HitchSampler
extends RefCounted
# Which hitch frames become "hitch" events (telemetry v2). It replaces "the first 40 per run", which
# stored 3% of real stalls and only the early ones. Pure: Telemetry feeds it wall-clock seconds.
# - A hitch whose recent tags carry a first-use marker ("new:") or that compiled pipelines always
# goes: those are the fixable ones.
# - Otherwise only the worst WORST_PER_WINDOW per WINDOW_S window, sent when the window closes.
# - Never more than MAX_PER_RUN in total (one sampler per run: Telemetry is per-run).
const WINDOW_S := 30.0
const WORST_PER_WINDOW := 2
const MAX_PER_RUN := 200
var sent: int = 0
var _window_start: float = -1.0
var _held: Array[Dictionary] = [] # worst first
static func must_send(h: Dictionary) -> bool:
return String(h.get("recent", "")).contains("new:") or int(h.get("pipelines", 0)) > 0
func offer(h: Dictionary, now_s: float) -> Array[Dictionary]:
var out := poll(now_s)
if must_send(h):
var one: Array[Dictionary] = [h]
out.append_array(_take(one))
return out
_held.append(h)
_held.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return float(a.frame_ms) > float(b.frame_ms))
if _held.size() > WORST_PER_WINDOW:
_held.resize(WORST_PER_WINDOW)
return out
func poll(now_s: float) -> Array[Dictionary]:
if _window_start < 0.0:
_window_start = now_s
if now_s - _window_start < WINDOW_S:
var none: Array[Dictionary] = []
return none
_window_start = now_s
return drain()
func drain() -> Array[Dictionary]:
var held := _held
_held = []
return _take(held)
func _take(hs: Array[Dictionary]) -> Array[Dictionary]:
var room := maxi(MAX_PER_RUN - sent, 0)
var out: Array[Dictionary] = []
for h in hs:
if out.size() >= room:
break
out.append(h)
sent += out.size()
return out
  • Step 4: Remove the old cap from net/hitch_tracker.gd

  • Delete the line const MAX_PER_SESSION := 40 # hard cap on hitch rows one run can send.

  • Delete the line var sent: int = 0.

  • Delete the whole can_send() function at the end of the file.

  • In the header comment, after # Pure logic, no Node / HTTP: the Telemetry node feeds it and ships what it returns., add the line # Which hitches are sent is HitchSampler's job (telemetry v2).

In tests/test_hitch_tracker.gd, delete test_session_cap (the 4 lines starting func test_session_cap() -> void:).

  • Step 5: Rewrite net/telemetry.gd (the Task 3 file plus render times and the sampler)
class_name Telemetry
extends Node
# Render-side perf reporter (NOT /sim). Every INTERVAL seconds pushes a "sample" event, and
# HitchSampler-selected stalled frames as "hitch" events, onto the persistent EventQueue
# (telemetry v2), which owns the network, the disk queue and retries. Created fresh per run by
# main. Silent in editor/headless and never affects play.
# proc_ms / render_ms are Performance.TIME_PROCESS: the WHOLE frame's process time, not our
# script alone and not rendering. Real render cost is gpu_ms / render_cpu_ms (measured viewport
# render time; null where the renderer can't measure, e.g. web compatibility).
const INTERVAL := 5.0
const PIPELINE_MONITORS := [
Performance.PIPELINE_COMPILATIONS_CANVAS, Performance.PIPELINE_COMPILATIONS_MESH,
Performance.PIPELINE_COMPILATIONS_SURFACE, Performance.PIPELINE_COMPILATIONS_DRAW,
Performance.PIPELINE_COMPILATIONS_SPECIALIZATION,
]
var low_fx: bool = false # set by main: true whenever adaptive quality has stepped below full
var quality: int = 0 # current adaptive quality tier (0 = full, higher = more stripped)
var _sim: Sim
var _queue: EventQueue
var _accum: float = 0.0
var _enabled: bool = false
# Hitch capture (2026-09-26): every stalled frame, with what happened just before it.
var hitches := HitchTracker.new()
var sampler := HitchSampler.new() # telemetry v2: which of them are sent
var state_fn: Callable # set by main: returns "play" / "levelup" / "pause" / ...
var _pipelines_last: int = -1
var _last_state: String = ""
func setup(sim: Sim, queue: EventQueue) -> void:
_sim = sim
_queue = queue
_enabled = queue != null and EventQueue.live_platform()
func _ready() -> void:
if _enabled:
# Idempotent; per-run node, so re-asserted each run. Costs a GPU timestamp query per frame.
RenderingServer.viewport_set_measure_render_time(get_viewport().get_viewport_rid(), true)
func _exit_tree() -> void:
flush_held() # app quitting mid-run: main flushes explicitly before a session change
# Held (not-yet-sent) worst-of-window hitches go out now. main calls this before the run's
# session id changes (menu / next run), so they still carry this run's session.
func flush_held() -> void:
for h in sampler.drain():
_push("hitch", h)
func _notification(what: int) -> void:
if what == NOTIFICATION_APPLICATION_PAUSED or what == NOTIFICATION_APPLICATION_FOCUS_OUT \
or what == NOTIFICATION_APPLICATION_RESUMED or what == NOTIFICATION_APPLICATION_FOCUS_IN:
hitches.skip_next()
# Main forwards each frame's drained render fx events, so a stall can be tied to the effect that
# was first drawn just before it (first use of a shader is the usual suspect).
func note_events(events: Array) -> void:
if not _enabled:
return
for ev in events:
var kind := String(ev.get("kind", ""))
if kind == "reaction" and ev.has("name"):
kind = "reaction:" + String(ev.name)
hitches.note(kind)
func note(tag: String) -> void:
if _enabled:
hitches.note(tag)
static func pipelines_total() -> int:
var n := 0
for m in PIPELINE_MONITORS:
n += int(Performance.get_monitor(m))
return n
# Measured viewport render time (ms). 0 = not measured (unsupported renderer / not enabled) -> null.
static func render_times(vp: RID) -> Dictionary:
var gpu := RenderingServer.viewport_get_measured_render_time_gpu(vp)
var cpu := RenderingServer.viewport_get_measured_render_time_cpu(vp) + RenderingServer.get_frame_setup_time_cpu()
var out := {"gpu_ms": null, "render_cpu_ms": null}
if gpu > 0.0:
out["gpu_ms"] = gpu
if cpu > 0.0:
out["render_cpu_ms"] = cpu
return out
func _push(t: String, d: Dictionary) -> void:
if _queue != null:
_queue.push(t, d)
func _track_frame() -> void:
var f := hitches.frame(Time.get_ticks_usec())
var pipes := pipelines_total()
var new_pipes := 0 if _pipelines_last < 0 else pipes - _pipelines_last
_pipelines_last = pipes
if new_pipes > 0:
hitches.note("pipelines")
var state := String(state_fn.call()) if state_fn.is_valid() else ""
if state != _last_state:
_last_state = state
hitches.note("state:" + state) # a panel opening / boss arriving, right before a stall
var now_s := float(Time.get_ticks_msec()) / 1000.0
var out: Array[Dictionary] = []
if bool(f.hitch):
out = sampler.offer(_hitch_dict(float(f.ms), new_pipes, state), now_s)
else:
out = sampler.poll(now_s)
for h in out:
_push("hitch", h)
func _hitch_dict(frame_ms: float, new_pipes: int, state: String) -> Dictionary:
return {
"frame_ms": frame_ms,
"proc_ms": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0, # whole-frame TIME_PROCESS
"phys_ms": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0,
"pipelines": new_pipes,
"run_time": _sim.run_time,
"enemies": _sim.enemies.count,
"draws": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
"nodes": Performance.get_monitor(Performance.OBJECT_NODE_COUNT),
"state": state,
"recent": hitches.recent_tags(),
"quality": quality,
}
func _send_sample() -> void:
var win := hitches.take_window()
var fps := float(Engine.get_frames_per_second())
var d := {
"config_id": RemoteConfig.active_id,
"fps": fps,
"frame_ms": 1000.0 / maxf(fps, 1.0),
"sim_ms": Performance.get_monitor(Performance.TIME_PHYSICS_PROCESS) * 1000.0,
"render_ms": Performance.get_monitor(Performance.TIME_PROCESS) * 1000.0, # whole-frame TIME_PROCESS (legacy name)
"draws": Performance.get_monitor(Performance.RENDER_TOTAL_DRAW_CALLS_IN_FRAME),
"prims": Performance.get_monitor(Performance.RENDER_TOTAL_PRIMITIVES_IN_FRAME),
"enemies": _sim.enemies.count,
"zones": _sim.zones.size(),
"webs": _sim.webs.size(),
"run_time": _sim.run_time,
"low_fx": low_fx,
"quality": quality,
"max_frame_ms": win.max_frame_ms, # worst single frame in this 5s window
"hitches": win.hitches, # frames over HitchTracker.HITCH_MS in it
}
d.merge(render_times(get_viewport().get_viewport_rid()) if is_inside_tree() else {"gpu_ms": null, "render_cpu_ms": null})
_push("sample", d)
func _process(dt: float) -> void:
if not _enabled or _sim == null:
return
_track_frame()
_accum += dt
if _accum < INTERVAL:
return
_accum = 0.0
_send_sample()
  • Step 6: Flush held hitches before the session changes (main.gd)

In _return_to_menu, directly after the Task 4 line _send_run_end("quit_menu"), add:

if is_instance_valid(telemetry):
telemetry.flush_held() # worst-of-window hitches still carry this run's session

In _new_run, directly after the Task 4 line _send_run_end("dev_restart"), add the same two lines.

(The death path needs nothing: the session only changes at the menu or the next _new_run, and both now flush first.)

  • Step 7: Fix the dashboard and schema labels, and add the GPU column

In telemetry/src/worker.js, in the agg query, replace:

ROUND(AVG(sim_ms),2) avg_sim_ms, ROUND(AVG(render_ms),2) avg_render_ms,

with:

ROUND(AVG(sim_ms),2) avg_sim_ms, ROUND(AVG(render_ms),2) avg_render_ms,
ROUND(AVG(gpu_ms),2) avg_gpu_ms,

In dash()’s row, replace:

r.avg_sim_ms, r.avg_render_ms, r.avg_draws, r.max_enemies, r.pct_below_40fps + "%",

with:

r.avg_sim_ms, r.avg_render_ms, r.avg_gpu_ms ?? "", r.avg_draws, r.max_enemies, r.pct_below_40fps + "%",

Replace the perf table header:

<table><tr><th>build<th>samples<th>sessions<th>avg fps<th>min fps<th>frame ms<th>sim ms<th>render ms<th>draws<th>max enemies<th>&lt;40fps<th>qual a/m</tr>

with:

<table><tr><th>build<th>samples<th>sessions<th>avg fps<th>min fps<th>frame ms<th>sim ms<th>process ms<th>gpu ms<th>draws<th>max enemies<th>&lt;40fps<th>qual a/m</tr>

In the intro paragraph, replace sim/render ms are CPU time per frame; with:

sim ms = physics step time; process ms (column formerly "render ms") = Performance.TIME_PROCESS, the whole frame's process time, NOT rendering; gpu ms = measured viewport GPU time (blank = pre-v2 build or a renderer that can't measure, e.g. web);

In the hitch paragraph, replace proc = CPU script ms that frame (high = our code; low with a big frame = render/driver). with:

proc = Performance.TIME_PROCESS for that frame: the whole frame's process time, not our script alone, so it cannot split "our code" from "render/driver" (fixed 2026-09-29; use samples gpu ms for render cost).

In telemetry/schema.sql, replace:

-- proc_ms high = script/CPU work in that frame; proc_ms low with a big frame_ms = render/driver.

with:

-- proc_ms = Performance.TIME_PROCESS: the WHOLE frame's process time, not script-only (the old
-- "high = our code" reading was wrong; corrected 2026-09-29). Render cost: samples.gpu_ms/render_cpu_ms.
  • Step 8: Run the tests to verify they pass

Run: godot --headless --path . --import; for t in test_hitch_sampler test_hitch_tracker test_telemetry_reporters_queue; do godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=$t -gexit || break; done; node --test telemetry/test/*.test.mjs Expected: PASS everywhere. The Task 3 hitch-node test still passes: it calls reset_seen(), so its hitch carries new: and is must-send.

  • Step 9: Gate and commit

Run: bash scripts/gate.sh → == GATE PASS.

Terminal window
cat > /tmp/bh-msg.txt <<'EOF'
feat(telemetry): gpu_ms/render_cpu_ms on samples, hitch sampler, honest proc/render labels
Telemetry v2 Task 6. Hitches: first-use/pipeline stalls always sent, else worst 2 per 30 s,
200/run cap (was the first 40). Samples gain measured viewport GPU/CPU render time (null
when unmeasurable). Dashboard/schema no longer claim proc_ms is "our code".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gk
EOF
git add net/hitch_sampler.gd net/hitch_tracker.gd net/telemetry.gd main.gd telemetry/src/worker.js telemetry/schema.sql tests/test_hitch_sampler.gd tests/test_hitch_tracker.gd tests/test_telemetry_reporters_queue.gd
git commit -F /tmp/bh-msg.txt

Task 7: “Share diagnostics” toggle, install id rotation, and the privacy answers

Section titled “Task 7: “Share diagnostics” toggle, install id rotation, and the privacy answers”

Files:

  • Modify: sim/meta_state.gd (the share_diagnostics field plus to_dict/from_dict; data only, never read by tick)
  • Modify: meta/meta_store.gd (ensure_install_id respects the toggle; new set_share_diagnostics)
  • Modify: net/remote_config.gd (static fetch_url, set_install_id)
  • Modify: ui/pause_menu.gd (the toggle button)
  • Modify: main.gd (pass the state in, handle the toggle, pass share to setup/boot)
  • Modify: tests/test_pause_menu.gd:44 (button count 8/10 → 9/11)
  • Modify (full rewrite shown): marketing/appstore/compliance.md
  • Test: tests/test_meta_state.gd, tests/test_diagnostics_toggle.gd (new)

Interfaces:

  • Consumes:

    • EventQueue.set_enabled(on: bool, install_id: String) and EventQueue.setup(install_id, share) (Task 2).
    • LifecycleTelemetry.boot(first_launch, share) (Task 4).
    • EventQueueCore.pending_count() (Task 2).
  • Produces:

    • MetaState.share_diagnostics: bool (default true).
    • MetaStore.set_share_diagnostics(m: MetaState, on: bool) -> void.
    • RemoteConfig.fetch_url(build: int, channel: String, install: String) -> String (static), RemoteConfig.set_install_id(id: String) -> void.
    • PauseMenu.share_diagnostics: bool (set before add_child), signal diagnostics_toggled(on: bool), static PauseMenu.diagnostics_label(on: bool) -> String, PauseMenu._diag_btn: Button.
    • main._on_diagnostics_toggled(on: bool) -> void (Task 9 adds one line to it).
  • Behaviour:

    • Off: the queue is wiped at once, push becomes a no-op, the install id is cleared from the save, and /config is fetched without install=.
    • On again: a NEW install id is minted, so the old and new periods can’t be joined.
    • The toggle survives restarts (it is in the save).
    • A save this build could not read is never written: same last_load_compatible rule as ensure_install_id.
  • Determinism: share_diagnostics is never read by Sim, so the baseline cannot move. The gate proves it.

  • Step 1: Write the failing tests

Append to tests/test_meta_state.gd:

# ── Share diagnostics (telemetry v2, 2026-09-29) ──────────────────────────────────────────
func test_share_diagnostics_defaults_on_and_round_trips() -> void:
var m := MetaState.new()
assert_true(m.share_diagnostics)
m.share_diagnostics = false
var back := MetaState.new()
back.from_dict(m.to_dict())
assert_false(back.share_diagnostics)
func test_a_save_without_the_key_means_on() -> void:
var m := MetaState.new()
var d := m.to_dict()
d.erase("share_diagnostics")
var back := MetaState.new()
back.from_dict(d)
assert_true(back.share_diagnostics)

Create tests/test_diagnostics_toggle.gd:

extends GutTest
# "Share diagnostics" (telemetry v2 Task 7). MetaStore disk IO is off for the whole GUT run
# (tests/gut_pre_run.gd), so set_share_diagnostics' save is counted but never touches meta.json.
func test_ensure_install_id_does_nothing_while_sharing_is_off() -> void:
var m := MetaState.new()
m.share_diagnostics = false
MetaStore.ensure_install_id(m)
assert_eq(m.install_id, "")
func test_turning_off_clears_the_id_and_on_mints_a_new_one() -> void:
var m := MetaState.new()
MetaStore.ensure_install_id(m)
var first := m.install_id
assert_eq(first.length(), 32)
MetaStore.set_share_diagnostics(m, false)
assert_false(m.share_diagnostics)
assert_eq(m.install_id, "")
MetaStore.set_share_diagnostics(m, true)
assert_true(m.share_diagnostics)
assert_eq(m.install_id.length(), 32)
assert_ne(m.install_id, first, "rotated: the two periods can't be joined")
func test_fetch_url_leaves_install_out_when_empty() -> void:
var with_id := RemoteConfig.fetch_url(207, "dev", "abc")
assert_eq(with_id, RemoteConfig.ENDPOINT + "?build=207&channel=dev&install=abc")
var without := RemoteConfig.fetch_url(207, "dev", "")
assert_eq(without, RemoteConfig.ENDPOINT + "?build=207&channel=dev")
func test_pause_menu_toggle_flips_label_and_emits() -> void:
var p := PauseMenu.new()
p.share_diagnostics = true
add_child_autofree(p)
await get_tree().process_frame
assert_eq(p._diag_btn.text, "Diagnostics: On")
assert_true(p._buttons.has(p._diag_btn), "reachable by up/down nav")
watch_signals(p)
p._diag_btn.emit_signal("pressed")
assert_signal_emitted_with_parameters(p, "diagnostics_toggled", [false])
assert_eq(p._diag_btn.text, "Diagnostics: Off")
func test_pause_menu_starts_from_the_saved_state() -> void:
var p := PauseMenu.new()
p.share_diagnostics = false
add_child_autofree(p)
await get_tree().process_frame
assert_eq(p._diag_btn.text, "Diagnostics: Off")
func test_glass_feedback_and_diagnostics_share_one_row_that_fits() -> void:
var was := VisualLook.glass_ui
VisualLook.glass_ui = true
var p := PauseMenu.new()
add_child_autofree(p)
await get_tree().process_frame
var row := p._diag_btn.get_parent()
assert_true(row is HBoxContainer, "paired with Send feedback so the column stays under 720p")
assert_lte((row as Control).get_combined_minimum_size().x, 380.0, "labels don't widen the row")
VisualLook.glass_ui = was
func test_main_toggle_off_wipes_the_queue_and_on_rotates_the_id() -> void:
var m: Node = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m.event_queue.enable_for_test()
m.meta.share_diagnostics = true
MetaStore.ensure_install_id(m.meta)
var old_id: String = m.meta.install_id
m.event_queue.push("menu", {})
assert_eq(m.event_queue.core.pending_count(), 1)
m._on_diagnostics_toggled(false)
assert_eq(m.event_queue.core.pending_count(), 0, "queued events never leave the device")
assert_false(m.event_queue.is_enabled())
assert_eq(m.meta.install_id, "")
assert_eq(m.event_queue.push("menu", {}), "", "push is a no-op while off")
m._on_diagnostics_toggled(true)
assert_true(m.event_queue.is_enabled())
assert_ne(m.meta.install_id, old_id)
assert_eq(String(m.event_queue.core.ctx.install), m.meta.install_id)
assert_eq(m.remote_config._install_id, m.meta.install_id)

In tests/test_pause_menu.gd:44, change 8 if not BuildConfig.dev_tools() else 10 to 9 if not BuildConfig.dev_tools() else 11, and the message to "build label did not get counted into the nav button list (Diagnostics toggle adds one)".

  • Step 2: Run the tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_diagnostics_toggle -gexit Expected: FAIL (share_diagnostics is not a member of MetaState / PauseMenu).

  • Step 3: sim/meta_state.gd

After the var install_id: String = "" line (line 57), add:

var share_diagnostics: bool = true # pause-menu "Diagnostics" toggle (telemetry v2); off = no telemetry, no install id

In to_dict(), change "install_id": install_id, to "install_id": install_id, "share_diagnostics": share_diagnostics,.

In from_dict(), directly after install_id = String(d.get("install_id", "")), add:

share_diagnostics = bool(d.get("share_diagnostics", true)) # older saves: on (matches past behaviour)

No SCHEMA_VERSION bump: the key is additive and defaults on.

  • Step 4: meta/meta_store.gd

Replace ensure_install_id with:

# Remote config (spec 2026-09-27 §3.3): give `m` a random 128-bit anonymous id if it has none,
# persisting it only when the save on disk was absent or loaded cleanly (last load). A save this
# build could not read keeps an in-memory id for the session and is never overwritten by boot.
# Telemetry v2: no id at all while "Share diagnostics" is off.
static func ensure_install_id(m: MetaState) -> void:
if m.install_id != "" or not m.share_diagnostics:
return
m.install_id = Crypto.new().generate_random_bytes(16).hex_encode()
if last_load_compatible:
save_state(m)
# The pause-menu toggle. Off forgets the id; on mints a NEW one, so data from before and after an
# off period can't be joined (spec: rotation on re-enable).
static func set_share_diagnostics(m: MetaState, on: bool) -> void:
m.share_diagnostics = on
m.install_id = ""
if on:
m.install_id = Crypto.new().generate_random_bytes(16).hex_encode()
if last_load_compatible:
save_state(m)
  • Step 5: net/remote_config.gd

In _fetch(), replace:

if _http.request("%s?build=%d&channel=%s&install=%s" % [ENDPOINT, _build, BuildChannel.current(), _install_id.uri_encode()]) != OK:

with:

if _http.request(fetch_url(_build, BuildChannel.current(), _install_id)) != OK:

After setup(), add:

# No install id (diagnostics off) = no install parameter at all, not an empty one.
static func fetch_url(build: int, channel: String, install: String) -> String:
var url := "%s?build=%d&channel=%s" % [ENDPOINT, build, channel]
if install != "":
url += "&install=" + install.uri_encode()
return url
# The Diagnostics toggle: later refresh() calls use the rotated (or cleared) id.
func set_install_id(id: String) -> void:
_install_id = id
  • Step 6: ui/pause_menu.gd

After signal feedback_submitted(kind: String, note: String), add:

signal diagnostics_toggled(on: bool) # the "Diagnostics" toggle (telemetry v2); main persists it

After var hidden_entries: Array[String] = [] ..., add:

var share_diagnostics := true # set before add_child: the saved state the toggle starts from
var _diag_btn: Button

Replace:

if not hidden_entries.has("feedback"):
box.add_child(_make_btn("Send feedback", func() -> void: _open_feedback(), Color(0.4, 0.95, 0.85)))

with the block below. _make_btn appends to _buttons when it is called, and _diag_btn is created first (both branches need it), so it is taken out of _buttons and re-appended at the end. That keeps up/down nav in the visual order: Send feedback, then Diagnostics.

_diag_btn = _make_btn(diagnostics_label(share_diagnostics), func() -> void: _toggle_diagnostics(), Color(0.55, 0.62, 0.75))
_buttons.erase(_diag_btn) # re-appended below, after Send feedback, so nav follows the layout
if not hidden_entries.has("feedback"):
var fb := _make_btn("Send feedback", func() -> void: _open_feedback(), Color(0.4, 0.95, 0.85))
if _glass:
# Paired on one row (telemetry v2): a full-width Diagnostics row would push the column past
# 660 px with dev rows (test_glass_column_with_dev_rows_fits_720p). Nav order: fb, then diag.
var pair := HBoxContainer.new()
pair.alignment = BoxContainer.ALIGNMENT_CENTER
pair.add_theme_constant_override("separation", GLASS_SEPARATION)
for b: Button in [fb, _diag_btn]:
b.custom_minimum_size = Vector2((380.0 - GLASS_SEPARATION) / 2.0, GLASS_BTN_H)
b.add_theme_font_size_override("font_size", GLASS_DEV_BTN_FONT)
pair.add_child(b)
box.add_child(pair)
else:
box.add_child(fb)
box.add_child(_diag_btn)
else:
box.add_child(_diag_btn)
_buttons.append(_diag_btn)

(_make_btn’s pressed/hover lambdas look the button up with _buttons.find(b) at press time, so moving it in the array is safe.)

Add, next to _open_feedback:

static func diagnostics_label(on: bool) -> String:
return "Diagnostics: On" if on else "Diagnostics: Off"
func _toggle_diagnostics() -> void:
share_diagnostics = not share_diagnostics
_diag_btn.text = diagnostics_label(share_diagnostics)
diagnostics_toggled.emit(share_diagnostics)

If test_glass_feedback_and_diagnostics_share_one_row_that_fits fails on width (the 18 px title font being wider than 187 px for “Diagnostics: Off”), shorten the labels to "Data: On" / "Data: Off" in diagnostics_label and the tests. Do not shrink the font below 18 (TV legibility). After the change, capture the pause screen with the bh-visual-qa agent at iPhone, iPad and ATV sizes.

  • Step 7: main.gd

(a) In _toggle_pause, directly after pause_menu.hidden_entries = tutorial_pause_hidden(_mode, sim) ..., add:

pause_menu.share_diagnostics = meta.share_diagnostics # before add_child (builds the toggle)

and after the pause_menu.feedback_submitted.connect(...) statement, add:

pause_menu.diagnostics_toggled.connect(_on_diagnostics_toggled)

(b) In _ready, change the Task 3 line event_queue.setup(meta.install_id, true) to:

event_queue.setup(meta.install_id, meta.share_diagnostics)

and the Task 4 line lifecycle.boot(first_launch, true) to:

lifecycle.boot(first_launch, meta.share_diagnostics)

(first_launch reads install_id == "", which is also true for a player who switched diagnostics off. That’s harmless: with sharing off, the launch event is never sent.)

(c) Add the handler next to _lifecycle_info:

# Pause-menu "Diagnostics" toggle (telemetry v2). Off: forget the install id, wipe anything queued,
# stop pushing, and fetch remote config anonymously. On: a fresh id everywhere.
func _on_diagnostics_toggled(on: bool) -> void:
MetaStore.set_share_diagnostics(meta, on)
event_queue.set_enabled(on, meta.install_id)
remote_config.set_install_id(meta.install_id)
  • Step 8: Rewrite marketing/appstore/compliance.md
# Dark Cosmos — ASC compliance answers
Updated 2026-09-29 for telemetry v2 (spec docs/superpowers/specs/2026-09-29-telemetry-v2-design.md).
These answers must be live in App Store Connect BEFORE the first build carrying telemetry v2 is
submitted (the label is per app, not per build).
## App Privacy (Data Collection)
Does the app collect data? YES. Anonymous diagnostics, on by default, with an in-game opt-out
(pause menu > "Diagnostics: On/Off"). Turning it off stops all sending, deletes anything queued on
the device, and discards the install id (a new one is made if it is turned back on).
Data types:
- Identifiers > Device ID: a random install id (128-bit, generated on the device, stored in the
save file, never derived from hardware or IDFA/IDFV; rotated whenever Diagnostics is turned back on).
- Diagnostics > Crash Data: whether the previous launch ended abnormally (crash / killed in
background) with the last game state; on iOS, Apple MetricKit crash and hang reports; on iOS only
if enabled, crash reports sent to Sentry (processor).
- Diagnostics > Performance Data: frame rate, frame/physics/process/GPU timings, draw calls,
primitive counts, frame hitches and what preceded them, adaptive quality tier, app build number,
coarse device model (e.g. "iOS/iPhone15,2").
- Usage Data > Product Interaction: app launch/background/foreground, menu visits, tutorial steps,
shop purchases (item + price), run start/end with reason, level-ups offered and picked, XP by
enemy type, boss spawn/kill times, damage by weapon, plus the existing per-run summary (run
length, level, kills, gold, damage dealt/taken, weapons, upgrade picks, death cause, etc.).
- User Content > Other User Content: the optional "Send feedback" note the player types on the pause
menu, with its build/device/run context.
For ALL of the above: Linked to the user's identity? NO (no account, name, email or advertising id;
the install id is random and not tied to any identity). Used for tracking? NO. Purposes: App
Functionality + Analytics.
Processors: Cloudflare (Workers + D1, stores all of the above); Sentry (iOS crash reports only, and
only once it is switched on; not enabled at the time of writing).
## Age Rating
- Cartoon or Fantasy Violence: INFREQUENT/MILD (abstract neon shapes, no blood/gore)
- Realistic Violence / sexual / profanity / drugs / gambling / horror: NONE
- Unrestricted web access: NO
Expected: 9+.
## Export compliance
Uses non-exempt encryption? NO (ITSAppUsesNonExemptEncryption = false).
## URLs
Support URL / Marketing URL: https://darkcosmos.lumara.digital
Privacy Policy URL: https://darkcosmos.lumara.digital/privacy/ (must be updated to match this file
before submission; draft in docs/superpowers/plans/2026-09-29-telemetry-v2.md, Chris-gated section)

Keep the Sentry wording even while Sentry stays dormant (Task 9). Declaring a processor that is off is safe; switching it on without the declaration is not. [Likely]: Apple’s label covers data “collected”, so “Crash Data” is required as soon as MetricKit reports leave the device.

  • Step 9: Run the tests to verify they pass

Run: godot --headless --path . --import; for t in test_diagnostics_toggle test_meta_state test_pause_menu test_remote_config_wiring test_remote_config_refresh; do godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=$t -gexit || break; done Expected: PASS, including test_glass_column_with_dev_rows_fits_720p (the diagnostics button shares the feedback row, so the column height is unchanged).

  • Step 10: Gate and commit

Run: bash scripts/gate.sh → == GATE PASS (the baseline is unchanged: Sim never reads share_diagnostics).

Terminal window
cat > /tmp/bh-msg.txt <<'EOF'
feat(telemetry): Share diagnostics toggle, install id rotation, privacy answers
Telemetry v2 Task 7. Pause menu gains "Diagnostics: On/Off" (paired with Send feedback on the
glass look). Off wipes the event queue, stops pushing, clears the install id and drops it from
/config; on mints a fresh id. compliance.md now declares Device ID, Crash Data, feedback text.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gk
EOF
git add sim/meta_state.gd meta/meta_store.gd net/remote_config.gd ui/pause_menu.gd main.gd marketing/appstore/compliance.md tests/test_meta_state.gd tests/test_diagnostics_toggle.gd tests/test_pause_menu.gd
git commit -F /tmp/bh-msg.txt

Task 8: MetricKit on iOS (native sink, re-apply script, Godot pickup)

Section titled “Task 8: MetricKit on iOS (native sink, re-apply script, Godot pickup)”

Files:

  • Create: platform/tvos/patches/ios/metrickit-sink.mm
  • Create: scripts/release/apply-ios-metrickit.sh
  • Create: scripts/test/apply-ios-metrickit.test.mjs
  • Create: net/metrickit_pickup.gd
  • Modify: net/lifecycle_telemetry.gd (pick up at boot and on foreground)
  • Modify: scripts/release/build-ios.sh (apply after the export)
  • Modify: scripts/dev/install-dev.sh (apply in the iOS branch)
  • Modify: platform/tvos/patches/README.md (one row, one paragraph)
  • Local only (gitignored, never git add -f): .claude/skills/bh-appstore-release/SKILL.md, .claude/skills/bh-deploy/SKILL.md
  • Test: tests/test_metrickit_pickup.gd

Interfaces:

  • Consumes: EventQueue.push, EventQueue.is_enabled() (Task 2); LifecycleTelemetry._live, _queue, boot() and on_foreground() (Task 4).

  • Produces:

    • MetricKitPickup.PREFIX = "mk-", MetricKitPickup.MAX_JSON = 200000.
    • static MetricKitPickup.files_in(dir: String) -> PackedStringArray.
    • static MetricKitPickup.kind_of(file: String) -> String.
    • static MetricKitPickup.payload_for(file: String, text: String) -> Dictionary.
    • static MetricKitPickup.pick_up(dir: String, queue: EventQueue) -> int (returns the number sent).
    • Event metrickit, d = {kind, file, bytes, raw}, where raw is the payload’s JSON text. It is sent as a STRING, never re-parsed: Godot’s JSON parser turns every number into a float, which corrupts the 64-bit load addresses in MetricKit call-stack trees. truncated: true replaces raw above MAX_JSON.
  • Why the sink is APPENDED, not a replacement file:

    • The iOS export writes build-ios/BulletHeaven/dummy.cpp containing Godot’s own godot_apple_embedded_plugins_initialize/deinitialize stubs (verified in the current shell).
    • The script renames it to dummy.mm, types it sourcecode.cpp.objcpp in the pbxproj (the tvOS recipe from build-tvos.sh), and appends the sink. The stubs survive, whatever a future export generates.
  • Why an explicit link directive:

    • The file is ObjC++. Clang module autolinking for #import <MetricKit/MetricKit.h> is not reliable in ObjC++ without -fcxx-modules [Likely].
    • So the sink carries __asm__(".linker_option \"-framework\", \"MetricKit\""). Step 7 verifies the link with otool -L.
    • Fallback if the directive is rejected: add -framework MetricKit to both OTHER_LDFLAGS lines with sed in the apply script.
    • AS BUILT (review fix a092a21): MetricKit is weak-linked, and ld ignores -weak_framework in an object-file .linker_option. So the __asm__ line is gone: the apply script appends -weak_framework MetricKit to both OTHER_LDFLAGS lines, and the sink returns early if NSClassFromString(@"MXMetricManager") is nil. Step 7’s otool -L should show MetricKit as (weak). Dedupe keys on the payload’s SHA-256, not the time window. The pickup hangs off EventQueue via MetricKitPickup.attach() (called in main._ready after lifecycle.boot), not LifecycleTelemetry. It skips its scan in perf_bench/auto_showcase dev sessions.
  • Payload files live at the Caches root (deviation 4). The Godot side reads OS.get_cache_dir(). It is [Likely] the same NSCachesDirectory on iOS. The on-device check is in the Chris-gated section, step G6.

  • Privacy: the sink always writes, since native code can’t see the toggle. The pickup deletes every file and only sends when queue.is_enabled(). With diagnostics off nothing leaves the device, and nothing piles up either.

  • Timing: MetricKit delivers roughly once a day, on the launch after the report window closes. Diagnostics (crash/hang) arrive on the next launch after the event. Pickup runs at boot and on every foreground.

  • Step 1: Write the failing tests

Create scripts/test/apply-ios-metrickit.test.mjs:

import { test } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const SCRIPT = join(here, "..", "release", "apply-ios-metrickit.sh");
// The four dummy.cpp lines of a Godot 4.7 iOS pbxproj (verified against platform/tvos/build-ios).
const PBX = [
"\t\t1FF8DBB11FBA9DE1009DE660 /* dummy.cpp in Sources */ = {isa = PBXBuildFile; fileRef = 1FF8DBB01FBA9DE1009DE660 /* dummy.cpp */; };",
"\t\t1FF8DBB01FBA9DE1009DE660 /* dummy.cpp */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.cpp; path = dummy.cpp; sourceTree = \"<group>\"; };",
"\t\t\t\t1FF8DBB01FBA9DE1009DE660 /* dummy.cpp */,",
"\t\t\t\t1FF8DBB11FBA9DE1009DE660 /* dummy.cpp in Sources */,",
].join("\n") + "\n";
const STUB = "void godot_apple_embedded_plugins_initialize() {\n}\n\nvoid godot_apple_embedded_plugins_deinitialize() {\n}\n";
function shell() {
const dir = mkdtempSync(join(tmpdir(), "bh-mk-"));
mkdirSync(join(dir, "BulletHeaven"));
mkdirSync(join(dir, "BulletHeaven.xcodeproj"));
writeFileSync(join(dir, "BulletHeaven", "dummy.cpp"), STUB);
writeFileSync(join(dir, "BulletHeaven.xcodeproj", "project.pbxproj"), PBX);
return dir;
}
const run = (dir) => execFileSync("bash", [SCRIPT, dir], { encoding: "utf8" });
const count = (s, needle) => s.split(needle).length - 1;
test("renames dummy.cpp to dummy.mm, keeps the plugin stubs, appends the sink once", () => {
const dir = shell();
run(dir);
const mm = readFileSync(join(dir, "BulletHeaven", "dummy.mm"), "utf8");
assert.ok(!existsSync(join(dir, "BulletHeaven", "dummy.cpp")));
assert.ok(mm.includes("godot_apple_embedded_plugins_initialize"), "export's stubs survive");
assert.equal(count(mm, "BH_METRICKIT_SINK"), 1);
});
test("retypes the file reference as objcpp and leaves no dummy.cpp behind", () => {
const dir = shell();
run(dir);
const pb = readFileSync(join(dir, "BulletHeaven.xcodeproj", "project.pbxproj"), "utf8");
assert.equal(count(pb, "dummy.cpp"), 0);
assert.match(pb, /1FF8DBB01FBA9DE1009DE660 \/\* dummy\.mm \*\/ = \{isa = PBXFileReference; lastKnownFileType = sourcecode\.cpp\.objcpp; path = dummy\.mm;/);
});
test("is idempotent", () => {
const dir = shell();
run(dir);
run(dir);
const mm = readFileSync(join(dir, "BulletHeaven", "dummy.mm"), "utf8");
assert.equal(count(mm, "BH_METRICKIT_SINK"), 1);
});
test("fails loudly when the shell has no dummy file", () => {
const dir = shell();
execFileSync("rm", [join(dir, "BulletHeaven", "dummy.cpp")]);
assert.throws(() => run(dir));
});

Create tests/test_metrickit_pickup.gd:

extends GutTest
# MetricKitPickup (telemetry v2 Task 8): payload files from the iOS MetricKit sink become
# "metrickit" events and are always deleted, sent or not.
const DIR := "user://mk_test"
func before_each() -> void:
DirAccess.make_dir_recursive_absolute(DIR)
for f in DirAccess.get_files_at(DIR):
DirAccess.remove_absolute(DIR.path_join(f))
func after_each() -> void:
for f in DirAccess.get_files_at(DIR):
DirAccess.remove_absolute(DIR.path_join(f))
DirAccess.remove_absolute(DIR)
func _drop(name: String, text: String) -> void:
var f := FileAccess.open(DIR.path_join(name), FileAccess.WRITE)
f.store_string(text)
f.close()
func _queue() -> EventQueue:
var q := EventQueue.new()
add_child_autofree(q)
q.setup("inst", true)
q.enable_for_test()
return q
func test_only_mk_json_files_are_picked_up() -> void:
_drop("mk-diagnostic-1-2.json", "{}")
_drop("telemetry_queue.txt", "x")
_drop("mk-notes.txt", "x")
assert_eq(MetricKitPickup.files_in(DIR), PackedStringArray(["mk-diagnostic-1-2.json"]))
func test_kind_comes_from_the_name() -> void:
assert_eq(MetricKitPickup.kind_of("mk-diagnostic-1727600000000-42.json"), "diagnostic")
assert_eq(MetricKitPickup.kind_of("mk-metric-1727600000000-42.json"), "metric")
assert_eq(MetricKitPickup.kind_of("mk-.json"), "unknown")
func test_raw_json_travels_as_text_so_big_addresses_survive() -> void:
var text := "{\"address\":18446744073709551615}"
var d := MetricKitPickup.payload_for("mk-diagnostic-1-2.json", text)
assert_eq(d.raw, text, "never re-parsed: Godot would round the address to a float")
assert_eq(d.kind, "diagnostic")
assert_eq(int(d.bytes), text.length())
func test_oversize_payloads_are_stubbed_not_cut() -> void:
var d := MetricKitPickup.payload_for("mk-metric-1-2.json", "x".repeat(MetricKitPickup.MAX_JSON + 1))
assert_true(bool(d.truncated))
assert_false(d.has("raw"))
func test_enabled_queue_gets_one_event_per_file_and_files_go() -> void:
var q := _queue()
_drop("mk-diagnostic-1-2.json", "{\"a\":1}")
_drop("mk-metric-3-4.json", "{\"b\":2}")
assert_eq(MetricKitPickup.pick_up(DIR, q), 2)
var evs := q.core.pending_events()
assert_eq(evs.size(), 2)
assert_eq(String(evs[0].t), "metrickit")
assert_eq(MetricKitPickup.files_in(DIR).size(), 0)
func test_diagnostics_off_deletes_without_sending() -> void:
var q := _queue()
q.set_enabled(false, "")
_drop("mk-diagnostic-1-2.json", "{}")
assert_eq(MetricKitPickup.pick_up(DIR, q), 0)
assert_eq(q.core.pending_count(), 0)
assert_eq(MetricKitPickup.files_in(DIR).size(), 0, "nothing piles up while off")
  • Step 2: Run them to verify they fail

Run: node --test scripts/test/apply-ios-metrickit.test.mjs; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_metrickit_pickup -gexit Expected: both FAIL (script missing; MetricKitPickup not declared).

  • Step 3: Create platform/tvos/patches/ios/metrickit-sink.mm
// ===== BH_METRICKIT_SINK (telemetry v2, docs/superpowers/plans/2026-09-29-telemetry-v2.md Task 8) =====
// Appended by scripts/release/apply-ios-metrickit.sh to the Godot-generated dummy.cpp (renamed
// dummy.mm, typed objcpp); the export's plugin stubs above stay untouched. iOS only: MetricKit
// does not exist on tvOS. Each payload is written as <Caches>/mk-<kind>-<ms>-<rand>.json and
// net/metrickit_pickup.gd sends + deletes it. Nothing here touches Godot.
#import <TargetConditionals.h>
#if TARGET_OS_IOS
#import <Foundation/Foundation.h>
#import <MetricKit/MetricKit.h>
// ObjC++ does not reliably autolink frameworks from #import; ask the linker directly.
__asm__(".linker_option \"-framework\", \"MetricKit\"");
@interface BHMetricKitSink : NSObject <MXMetricManagerSubscriber>
@end
@implementation BHMetricKitSink
// pastPayloads can hand back reports didReceive already delivered on an earlier launch; remember
// the last 64 by kind + report window so each one is written once.
+ (BOOL)firstTime:(NSString *)key {
NSUserDefaults *u = [NSUserDefaults standardUserDefaults];
NSMutableArray *seen = [[u arrayForKey:@"bh_mk_seen"] mutableCopy] ?: [NSMutableArray array];
if ([seen containsObject:key]) return NO;
[seen addObject:key];
while (seen.count > 64) [seen removeObjectAtIndex:0];
[u setObject:seen forKey:@"bh_mk_seen"];
return YES;
}
+ (void)take:(NSData *)json kind:(NSString *)kind begin:(NSDate *)b end:(NSDate *)e {
if (json.length == 0) return;
NSString *key = [NSString stringWithFormat:@"%@:%.0f:%.0f", kind, b.timeIntervalSince1970, e.timeIntervalSince1970];
if (![self firstTime:key]) return;
NSString *dir = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
long long ms = (long long)([[NSDate date] timeIntervalSince1970] * 1000.0);
NSString *name = [NSString stringWithFormat:@"mk-%@-%lld-%u.json", kind, ms, arc4random()];
[json writeToFile:[dir stringByAppendingPathComponent:name] atomically:YES];
}
+ (void)takeMetrics:(NSArray<MXMetricPayload *> *)payloads {
for (MXMetricPayload *p in payloads)
[self take:[p JSONRepresentation] kind:@"metric" begin:p.timeStampBegin end:p.timeStampEnd];
}
+ (void)takeDiagnostics:(NSArray<MXDiagnosticPayload *> *)payloads {
for (MXDiagnosticPayload *p in payloads)
[self take:[p JSONRepresentation] kind:@"diagnostic" begin:p.timeStampBegin end:p.timeStampEnd];
}
- (void)didReceiveMetricPayloads:(NSArray<MXMetricPayload *> *)payloads {
[BHMetricKitSink takeMetrics:payloads];
}
- (void)didReceiveDiagnosticPayloads:(NSArray<MXDiagnosticPayload *> *)payloads {
[BHMetricKitSink takeDiagnostics:payloads];
}
@end
static BHMetricKitSink *bh_mk_sink = nil;
// Runs before main(); the block runs once UIApplicationMain starts the main run loop.
// Deployment target is iOS 15 (export_presets.cfg), so every API here is available unguarded.
__attribute__((constructor)) static void bh_metrickit_start(void) {
dispatch_async(dispatch_get_main_queue(), ^{
bh_mk_sink = [BHMetricKitSink new];
MXMetricManager *m = [MXMetricManager sharedManager];
[m addSubscriber:bh_mk_sink];
[BHMetricKitSink takeMetrics:m.pastPayloads];
[BHMetricKitSink takeDiagnostics:m.pastDiagnosticPayloads];
});
}
#endif
// ===== end BH_METRICKIT_SINK =====
  • Step 4: Create scripts/release/apply-ios-metrickit.sh (then chmod +x)
#!/usr/bin/env bash
# Telemetry v2 Task 8: re-apply the iOS MetricKit sink to a freshly exported iOS Xcode shell.
# A full --export-release regenerates build-ios/ (dummy.cpp included), so build-ios.sh and
# install-dev.sh (iOS) run this every time. Idempotent.
# Usage: scripts/release/apply-ios-metrickit.sh [shell dir] (default: platform/tvos/build-ios)
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
SHELL_DIR=${1:-$ROOT/platform/tvos/build-ios}
SRC=$ROOT/platform/tvos/patches/ios/metrickit-sink.mm
D=$SHELL_DIR/BulletHeaven
PB=$SHELL_DIR/BulletHeaven.xcodeproj/project.pbxproj
G=/usr/bin/grep
[ -f "$D/dummy.cpp" ] && mv "$D/dummy.cpp" "$D/dummy.mm"
[ -f "$D/dummy.mm" ] || { echo "apply-ios-metrickit: no dummy.cpp or dummy.mm in $D"; exit 1; }
$G -q BH_METRICKIT_SINK "$D/dummy.mm" || cat "$SRC" >> "$D/dummy.mm"
sed -i '' 's/dummy\.cpp/dummy.mm/g' "$PB"
UUID=$($G -o '[0-9A-F]\{24\} /\* dummy.mm \*/ = {isa = PBXFileReference' "$PB" | cut -c1-24)
[ -n "$UUID" ] || { echo "apply-ios-metrickit: no dummy.mm file reference in $PB"; exit 1; }
sed -i '' "/$UUID .*PBXFileReference/s/sourcecode\.cpp\.cpp/sourcecode.cpp.objcpp/" "$PB"
[ "$($G -c dummy.cpp "$PB" || true)" = 0 ] || { echo "apply-ios-metrickit: dangling dummy.cpp reference"; exit 1; }
$G "$UUID .*PBXFileReference" "$PB" | $G -q objcpp || { echo "apply-ios-metrickit: dummy.mm not typed objcpp"; exit 1; }
$G -q godot_apple_embedded_plugins_initialize "$D/dummy.mm" || { echo "apply-ios-metrickit: plugin stubs lost"; exit 1; }
echo "apply-ios-metrickit: ok ($D/dummy.mm)"

Run: chmod +x scripts/release/apply-ios-metrickit.sh

  • Step 5: Create net/metrickit_pickup.gd
class_name MetricKitPickup
extends RefCounted
# Telemetry v2 Task 8 (iOS only). The native sink (platform/tvos/patches/ios/metrickit-sink.mm)
# writes each MetricKit payload as <Caches>/mk-<kind>-<ms>-<rand>.json. Each file becomes one
# "metrickit" event and is then deleted, sent or not: with diagnostics off nothing is sent and
# nothing piles up. The JSON goes as text (`raw`), never re-parsed, because Godot's parser turns
# every number into a float and would corrupt the 64-bit addresses in call-stack trees.
const PREFIX := "mk-"
const MAX_JSON := 200000 # escaped, it still fits EventQueueCore.BATCH_MAX_BYTES (400000)
static func files_in(dir: String) -> PackedStringArray:
var out := PackedStringArray()
for f in DirAccess.get_files_at(dir):
if f.begins_with(PREFIX) and f.ends_with(".json"):
out.append(f)
out.sort()
return out
# "mk-diagnostic-1727600000000-42.json" -> "diagnostic"
static func kind_of(file: String) -> String:
var parts := file.get_basename().split("-")
if parts.size() >= 3 and parts[1] != "":
return parts[1]
return "unknown"
static func payload_for(file: String, text: String) -> Dictionary:
var d := {"kind": kind_of(file), "file": file, "bytes": text.length()}
if text.length() > MAX_JSON:
d["truncated"] = true
else:
d["raw"] = text
return d
static func pick_up(dir: String, queue: EventQueue) -> int:
var sent := 0
for f in files_in(dir):
var path := dir.path_join(f)
if queue != null and queue.is_enabled():
queue.push("metrickit", payload_for(f, FileAccess.get_file_as_string(path)))
sent += 1
DirAccess.remove_absolute(path)
return sent

(kind_of("mk-.json"): get_basename() gives "mk-", which splits to ["mk", ""]. That has size 2, so the result is "unknown".)

  • Step 6: Hook it into net/lifecycle_telemetry.gd, build-ios.sh, install-dev.sh

In net/lifecycle_telemetry.gd, add:

# iOS MetricKit reports (Task 8): the native sink drops files in the cache dir; send + delete them.
func pick_up_metrickit() -> void:
if _live and OS.get_name() == "iOS":
MetricKitPickup.pick_up(OS.get_cache_dir(), _queue)

and call pick_up_metrickit() as the last line of boot() and as the last line of on_foreground().

In scripts/release/build-ios.sh, directly after the line grep -iE "^ERROR|SCRIPT ERROR" /tmp/bh-ios-export.log | head -5 || true, add:

Terminal window
bash "$TVOS/../../scripts/release/apply-ios-metrickit.sh" "$TVOS/build-ios" # telemetry v2: MetricKit sink

In scripts/dev/install-dev.sh, in the iOS branch, directly after the bash "$ROOT/scripts/dev/make-dev-icon.sh" ... line, add:

Terminal window
bash "$ROOT/scripts/release/apply-ios-metrickit.sh" "$ROOT/platform/tvos/build-ios" # idempotent; export-pack keeps the shell, a past full export may not have had it
  • Step 7: Run the tests, then prove it compiles and links

Run: node --test scripts/test/*.test.mjs; godot --headless --path . --import; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_metrickit_pickup -gexit Expected: PASS.

Compile check against the real shell. This is a local build only: no signing, no upload, no device.

Terminal window
cd /Users/chris/Claude/bullet-heaven/platform/tvos
cp -R build-ios /private/tmp/bh-ios-verify-shell # never touch the signed shell's state
bash ../../scripts/release/apply-ios-metrickit.sh /private/tmp/bh-ios-verify-shell
cd /private/tmp/bh-ios-verify-shell
xcodebuild -scheme BulletHeaven -configuration Release -sdk iphoneos -destination 'generic/platform=iOS' \
-derivedDataPath /private/tmp/bh-ios-verify CODE_SIGNING_ALLOWED=NO build > /private/tmp/bh-ios-verify.log 2>&1 \
|| { tail -30 /private/tmp/bh-ios-verify.log; exit 1; }
APP=/private/tmp/bh-ios-verify/Build/Products/Release-iphoneos/BulletHeaven.app/BulletHeaven
otool -L "$APP" | /usr/bin/grep MetricKit
nm "$APP" | /usr/bin/grep -c BHMetricKitSink

Expected:

  • ** BUILD SUCCEEDED ** in the log.
  • otool -L prints /System/Library/Frameworks/MetricKit.framework/MetricKit.
  • The nm count is ≥ 1.

If the link line is missing, add -framework MetricKit to OTHER_LDFLAGS in the apply script with sed -i '' 's/OTHER_LDFLAGS = "\$(LD_CLASSIC_\$(XCODE_VERSION_ACTUAL)) ";/OTHER_LDFLAGS = "$(LD_CLASSIC_$(XCODE_VERSION_ACTUAL)) -framework MetricKit";/' "$PB", then re-run. Afterwards: rm -rf /private/tmp/bh-ios-verify-shell /private/tmp/bh-ios-verify.

  • Step 8: Docs (patches README, local skills)

Add this row to the table in platform/tvos/patches/README.md:

| `ios/metrickit-sink.mm` | APPENDED to `../build-ios/BulletHeaven/dummy.cpp` (renamed `dummy.mm`, typed objcpp) | iOS MetricKit subscriber (telemetry v2): writes crash/hang/metric payloads to `<Caches>/mk-*.json` for `net/metrickit_pickup.gd`. Applied by `scripts/release/apply-ios-metrickit.sh` (run by `build-ios.sh` and `install-dev.sh`), never by hand-copy: the export's own plugin stubs in `dummy.cpp` must survive. |

and under the table add:

The iOS sink is the one patch that is appended, not copied, so re-apply it with the script (idempotent), not `cp`.

Local skill edits (both files are gitignored; edit in place, do not commit or force-add):

  • In .claude/skills/bh-appstore-release/SKILL.md, in the iOS section after the export step, add the line: iOS: build-ios.sh now runs scripts/release/apply-ios-metrickit.sh after the export (MetricKit sink). If you export by hand, run it yourself before archiving; the archive must show MetricKit in otool -L.

  • In .claude/skills/bh-deploy/SKILL.md, in the iOS device deploy steps, add: iOS shell: scripts/release/apply-ios-metrickit.sh platform/tvos/build-ios (idempotent) before xcodebuild, so dev builds carry the MetricKit sink.

  • Step 9: Gate and commit

Run: bash scripts/gate.sh → == GATE PASS.

Terminal window
cat > /tmp/bh-msg.txt <<'EOF'
feat(telemetry): iOS MetricKit sink + pickup
Telemetry v2 Task 8. A native subscriber appended to the iOS export's dummy.cpp (as dummy.mm)
writes MetricKit metric/diagnostic payloads to Caches; net/metrickit_pickup.gd sends each as a
"metrickit" event (raw JSON text) and deletes it, or just deletes it with diagnostics off.
build-ios.sh and install-dev.sh re-apply it via scripts/release/apply-ios-metrickit.sh.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gk
EOF
git add platform/tvos/patches/ios/metrickit-sink.mm platform/tvos/patches/README.md scripts/release/apply-ios-metrickit.sh scripts/release/build-ios.sh scripts/dev/install-dev.sh scripts/test/apply-ios-metrickit.test.mjs net/metrickit_pickup.gd net/lifecycle_telemetry.gd tests/test_metrickit_pickup.gd
git commit -F /tmp/bh-msg.txt

Task 9: Sentry on iOS, dormant until a DSN exists (with an abort path)

Section titled “Task 9: Sentry on iOS, dormant until a DSN exists (with an abort path)”

Files:

  • Create: scripts/release/fetch-sentry.sh
  • Create: net/sentry_boot.gd
  • Create: tools/sentry_probe/probe.gd (a one-shot check; kept for the next version bump)
  • Create: tests/test_sentry_boot.gd
  • Replace: the platform/tvos/addons symlink with a real directory holding a gut symlink (the sentry/ inside it is gitignored)
  • Modify: .gitignore, platform/tvos/project.godot ([sentry] section), platform/tvos/export_presets.cfg (tvOS + tvOS-Dev exclude_filter)
  • Modify: tests/test_release_presets.gd, scripts/release/build-ios.sh, scripts/dev/install-dev.sh, main.gd

Interfaces:

  • Consumes: main._on_diagnostics_toggled (Task 7), EventQueue.live_platform() (Task 2), BuildChannel.current().
  • Produces:
    • SentryBoot.DSN_FILE = "res://sentry_dsn.json".
    • static SentryBoot.should_init(os_name: String, dsn: String, share: bool) -> bool.
    • static SentryBoot.read_dsn(path: String = DSN_FILE) -> String.
    • static SentryBoot.apply(share: bool, install_id: String) -> void.
    • static var SentryBoot.initialised: bool.
  • Facts verified 2026-09-29 against the release zip (gh release download 2.2.0 -R getsentry/sentry-godot):
    • Asset: sentry-godot-2.2.0+407729a.zip, 39,497,142 bytes, sha256 539cca58ff4188dafcd1318e03396fa303ea1ae5b2eda6f96a055146af44b156.
    • sentry.gdextension declares compatibility_minimum = "4.5" and has libraries for macos, windows, linux, android, ios (libsentry.ios.{debug,release}.xcframework plus a Sentry.xcframework dependency) and web. There is no tvOS entry.
    • iOS + macOS binaries total 44.8 MB.
    • Project settings in the binary include sentry/options/auto_init, sentry/options/dsn, sentry/options/release, sentry/options/environment, sentry/options/send_default_pii.
    • Method-name strings include init, close, set_tag, is_enabled.
  • Not verified, so Step 4’s probe checks it before any wiring relies on it [Likely]:
    • SentrySDK is an Engine singleton.
    • init takes a Callable(options: SentryOptions).
    • SentryOptions exposes dsn / release / environment / send_default_pii as properties.
  • Vendoring decision (deviation from the spec’s “commit under addons/sentry/ and symlink it in”, see the handback notes):
    • The addon lives in platform/tvos/addons/sentry/, gitignored. scripts/release/fetch-sentry.sh fills it from the sha256-pinned release zip, keeping only sentry.gdextension plus bin/ios and bin/macos.
    • Why not commit it: 45 MB of binaries in git history for a dormant feature.
    • Why not root addons/: the root project runs the gate and the web export (see deviation 6). The root project never scans platform/tvos/ (it holds its own project.godot; verified: no res://platform entry in .godot/global_script_class_cache.cfg).
    • A clone without the fetch simply has no Sentry, so SentryBoot stays dormant. build-ios.sh and install-dev.sh (iOS) fetch before exporting.
  • Dormant means: no sentry_dsn.json, so init is never called, no signal handlers are installed and nothing is sent. The binary still ships inside iOS builds.

Abort criteria. If any of these trips: git revert this task’s commit, run rm -rf platform/tvos/addons/sentry (and delete the res://addons/sentry/sentry.gdextension line from platform/tvos/.godot/extension_list.cfg, or the editor logs “Error loading GDExtension” on every export until it rescans), and record “Sentry parked: <which check, what output>” in the Task 11 docs. Don’t force it (spec: “parked rather than forced”). The criteria:

  1. The Step 4 probe does not find the SentrySDK singleton or its methods.
  2. The iOS no-signing xcodebuild in Step 8 fails.
  3. The tvOS-Dev pack contains addons/sentry/bin, or the tvOS export log shows a GDExtension error.
  4. The Dev app boot on the ATV logs a GDExtension/sentry error (Step 8, last part).
  5. The fetched files exceed 60 MB.
  • Step 1: Write the failing tests

Create tests/test_sentry_boot.gd:

extends GutTest
# SentryBoot (telemetry v2 Task 9): iOS only, needs a DSN and diagnostics on; a no-op wherever the
# GDExtension isn't loaded (the root project, which is where GUT runs).
const TMP := "user://sentry_dsn_test.json"
func after_each() -> void:
DirAccess.remove_absolute(TMP)
SentryBoot.initialised = false
func test_should_init_needs_ios_a_dsn_and_consent() -> void:
assert_true(SentryBoot.should_init("iOS", "https://k@o1.ingest.sentry.io/1", true))
assert_false(SentryBoot.should_init("tvOS", "https://k@o1.ingest.sentry.io/1", true))
assert_false(SentryBoot.should_init("Web", "https://k@o1.ingest.sentry.io/1", true))
assert_false(SentryBoot.should_init("iOS", "", true))
assert_false(SentryBoot.should_init("iOS", "https://k@o1.ingest.sentry.io/1", false))
func test_read_dsn_absent_or_broken_is_empty() -> void:
assert_eq(SentryBoot.read_dsn("user://no_such_file.json"), "")
var f := FileAccess.open(TMP, FileAccess.WRITE)
f.store_string("not json")
f.close()
assert_eq(SentryBoot.read_dsn(TMP), "")
func test_read_dsn_trims() -> void:
var f := FileAccess.open(TMP, FileAccess.WRITE)
f.store_string("{\"dsn\": \" https://k@o1.ingest.sentry.io/1 \"}")
f.close()
assert_eq(SentryBoot.read_dsn(TMP), "https://k@o1.ingest.sentry.io/1")
func test_apply_without_the_extension_is_a_no_op() -> void:
assert_false(Engine.has_singleton(SentryBoot.SINGLETON), "root project never loads sentry-godot")
SentryBoot.apply(true, "inst")
assert_false(SentryBoot.initialised)

Append to tests/test_release_presets.gd. First add an "exclude" field to the dictionary _presets() builds:

"exclude": str(cf.get_value(s, "exclude_filter", "")),

(Put it directly after the "features": ... line.) Then add:

func test_tvos_presets_exclude_sentry_and_ios_presets_keep_it() -> void:
# sentry-godot has no tvOS library (telemetry v2 Task 9); iOS must still ship it.
for p in _presets():
var parts: Array = Array(p.exclude.split(",")).map(func(x: String) -> String: return x.strip_edges())
if p.name.begins_with("tvOS"):
assert_true("addons/sentry/*" in parts, "%s must exclude addons/sentry/*" % p.name)
else:
assert_false("addons/sentry/*" in parts, "%s must not exclude sentry" % p.name)
  • Step 2: Run them to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_sentry_boot -gexit; godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_release_presets -gexit Expected: FAIL (SentryBoot not declared; tvOS presets lack the exclude).

  • Step 3: Fetch script, directory swap, ignores, the auto_init setting, preset excludes

Create scripts/release/fetch-sentry.sh (then chmod +x):

#!/usr/bin/env bash
# Telemetry v2 Task 9: put the pinned sentry-godot GDExtension (iOS + macOS libraries only) into
# platform/tvos/addons/sentry/. That dir is gitignored: ~45 MB of binaries stay out of git. Pinned
# by sha256, idempotent. build-ios.sh and install-dev.sh (iOS) run it before exporting. No tvOS
# library exists upstream; the tvOS presets exclude addons/sentry/*.
set -euo pipefail
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
VER=2.2.0
ASSET="sentry-godot-2.2.0+407729a.zip"
URL="https://github.com/getsentry/sentry-godot/releases/download/$VER/sentry-godot-2.2.0%2B407729a.zip"
SHA=539cca58ff4188dafcd1318e03396fa303ea1ae5b2eda6f96a055146af44b156
DEST=$ROOT/platform/tvos/addons/sentry
CACHE=${BH_SENTRY_CACHE:-$HOME/Library/Caches/bh-sentry}
sha() { shasum -a 256 "$1" | cut -d' ' -f1; }
if [ -f "$DEST/.pinned" ] && [ "$(cat "$DEST/.pinned")" = "$SHA" ]; then echo "fetch-sentry: $VER already in place"; exit 0; fi
mkdir -p "$CACHE"
Z=$CACHE/$ASSET
if [ ! -f "$Z" ] || [ "$(sha "$Z")" != "$SHA" ]; then curl -fsSL -o "$Z" "$URL"; fi
[ "$(sha "$Z")" = "$SHA" ] || { echo "fetch-sentry: checksum mismatch for $Z, refusing"; exit 1; }
TMP=$(mktemp -d)
unzip -q "$Z" 'addons/sentry/sentry.gdextension' 'addons/sentry/bin/ios/*' 'addons/sentry/bin/macos/*' -d "$TMP"
rm -rf "$DEST"
mv "$TMP/addons/sentry" "$DEST"
rm -rf "$TMP"
echo "$SHA" > "$DEST/.pinned"
echo "fetch-sentry: $VER -> $DEST ($(du -sh "$DEST" | cut -f1))"

Swap the symlink for a real directory. Nothing under root addons/ changes.

Terminal window
cd /Users/chris/Claude/bullet-heaven
git rm --cached platform/tvos/addons
rm platform/tvos/addons # the symlink itself, not its target
mkdir platform/tvos/addons
ln -s ../../../addons/gut platform/tvos/addons/gut
ls platform/tvos/addons/gut/gut_cmdln.gd # must resolve
chmod +x scripts/release/fetch-sentry.sh
bash scripts/release/fetch-sentry.sh
du -sm platform/tvos/addons/sentry # abort criterion 5: must be <= 60

Append to .gitignore:

# sentry-godot (telemetry v2 Task 9): fetched + sha256-pinned by scripts/release/fetch-sentry.sh
platform/tvos/addons/sentry/
# Sentry DSN, supplied by Chris; absent = Sentry dormant
platform/tvos/sentry_dsn.json

Check: git check-ignore platform/tvos/addons/sentry/sentry.gdextension platform/tvos/sentry_dsn.json prints both paths, and git status --short platform/tvos/addons shows only addons/gut.

Append to platform/tvos/project.godot:

[sentry]
options/auto_init=false

(SentryBoot decides when to init. The windowed exports rewrite project.godot and the scripts git checkout -- project.godot afterwards, so this must be COMMITTED before any export in Step 8, or it is silently lost.)

In platform/tvos/export_presets.cfg, add addons/sentry/* to the tvOS and tvOS-Dev presets only:

Terminal window
python3 - <<'EOF'
import re
p = "platform/tvos/export_presets.cfg"
s = open(p).read()
out, cur = [], None
for line in s.splitlines(keepends=True):
m = re.match(r'^name="(.*)"', line)
if m:
cur = m.group(1)
if line.startswith('exclude_filter="') and cur in ("tvOS", "tvOS-Dev") and "addons/sentry/*" not in line:
line = line.rstrip('\n').rstrip('"') + ', addons/sentry/*"\n'
out.append(line)
open(p, "w").write("".join(out))
EOF
/usr/bin/grep -n "^name=\|^exclude_filter" platform/tvos/export_presets.cfg

Expected: tvOS and tvOS-Dev show exclude_filter="tests/*, telemetry/*, tools/*, addons/sentry/*", and iOS / iOS-Dev are unchanged.

  • Step 4: Probe the real API before wiring it

Create tools/sentry_probe/probe.gd:

extends SceneTree
# One-shot: does the fetched sentry-godot expose what net/sentry_boot.gd calls? Run in the tvOS
# project on macOS (the macOS library loads there; nothing is initialised: auto_init=false).
# godot --headless --path platform/tvos -s res://tools/sentry_probe/probe.gd
func _init() -> void:
var ok := Engine.has_singleton("SentrySDK")
print("singleton SentrySDK: ", ok)
if ok:
var names: Array = ClassDB.class_get_method_list("SentrySDK").map(func(m: Dictionary) -> String: return m.name)
for want in ["init", "close", "set_tag", "is_enabled"]:
print("method %s: %s" % [want, names.has(want)])
for m in ClassDB.class_get_method_list("SentrySDK"):
if m.name == "init":
print("init args: ", m.args)
var props: Array = ClassDB.class_get_property_list("SentryOptions").map(func(p: Dictionary) -> String: return p.name)
for want in ["dsn", "release", "environment", "send_default_pii"]:
print("option %s: %s" % [want, props.has(want)])
quit(0 if ok else 1)

platform/tvos/tools is a symlink to root tools/, so the file is visible to both projects. Nothing in the root project loads it.

Run: godot --headless --path platform/tvos -s res://tools/sentry_probe/probe.gd; cd platform/tvos && git checkout -- project.godot; cd ../.. Expected:

  • singleton SentrySDK: true
  • all four methods true
  • init args shows one Callable argument
  • all four options true

If a name differs, change SentryBoot.apply to the probed names. If the singleton is missing, stop: abort criterion 1.

  • Step 5: Create net/sentry_boot.gd
class_name SentryBoot
extends RefCounted
# Telemetry v2 Task 9: iOS crash reporting through sentry-godot, DORMANT until Chris adds
# platform/tvos/sentry_dsn.json ({"dsn": "..."}; gitignored). The GDExtension only exists in the
# iOS/tvOS project (fetched by scripts/release/fetch-sentry.sh, excluded from the tvOS presets),
# never in the root project, so it is reached only through the Engine singleton: a static
# reference to SentrySDK would not compile here. Diagnostics off = never initialised / closed.
const DSN_FILE := "res://sentry_dsn.json"
const SINGLETON := "SentrySDK"
static var initialised: bool = false
static func should_init(os_name: String, dsn: String, share: bool) -> bool:
return os_name == "iOS" and dsn != "" and share
static func read_dsn(path: String = DSN_FILE) -> String:
if not FileAccess.file_exists(path):
return ""
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
if parsed is Dictionary:
return String((parsed as Dictionary).get("dsn", "")).strip_edges()
return ""
# At boot and on every Diagnostics toggle.
static func apply(share: bool, install_id: String) -> void:
if not Engine.has_singleton(SINGLETON):
return
var sdk: Object = Engine.get_singleton(SINGLETON)
if not share:
if initialised:
sdk.call("close")
initialised = false
return
if initialised:
return
var dsn := read_dsn()
if not should_init(OS.get_name(), dsn, share):
return
var release := "dark-cosmos@%d" % Sim_Const.BUILD
var env := BuildChannel.current()
sdk.call("init", func(options: Object) -> void:
options.set("dsn", dsn)
options.set("release", release)
options.set("environment", env)
options.set("send_default_pii", false))
sdk.call("set_tag", "install", install_id)
initialised = true

(The lambda reads the locals dsn / release / env and never assigns them, so capture-by-value is correct here.)

  • Step 6: Wire it (main.gd, build-ios.sh, install-dev.sh)

In main._ready, directly after the Task 4 lifecycle.boot(...) line, add:

if EventQueue.live_platform():
SentryBoot.apply(meta.share_diagnostics, meta.install_id) # iOS + DSN + consent only; else no-op

In _on_diagnostics_toggled (Task 7), add as the last line:

SentryBoot.apply(on, meta.install_id)

In scripts/release/build-ios.sh, directly after cd "$TVOS", add:

Terminal window
bash "$TVOS/../../scripts/release/fetch-sentry.sh" # telemetry v2: pinned sentry-godot (iOS libs)

In scripts/dev/install-dev.sh, in the iOS branch, as the first line (before the --export-pack "iOS-Dev" line), add:

Terminal window
bash "$ROOT/scripts/release/fetch-sentry.sh"

(--export-pack does not re-add a GDExtension to the Xcode shell. A Dev iPhone build only carries Sentry once build-ios/ has come from a full export with the addon present. That’s fine while Sentry is dormant. It’s written down in Task 11.)

  • Step 7: Run the tests, then gate and commit

Run: godot --headless --path . --import; for t in test_sentry_boot test_release_presets test_diagnostics_toggle; do godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=$t -gexit || break; done Expected: PASS.

Run: bash scripts/gate.sh → == GATE PASS.

Terminal window
cat > /tmp/bh-msg.txt <<'EOF'
feat(telemetry): dormant iOS Sentry (sentry-godot 2.2.0, pinned fetch, tvOS excluded)
Telemetry v2 Task 9. platform/tvos/addons becomes a real dir (gut symlink + gitignored sentry/
filled by scripts/release/fetch-sentry.sh, sha256-pinned, iOS+macOS libs only). auto_init off;
net/sentry_boot.gd inits only on iOS with a DSN file and diagnostics on, closes on opt-out.
tvOS presets exclude addons/sentry/* (no tvOS library upstream).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gk
EOF
git add .gitignore platform/tvos/addons platform/tvos/project.godot platform/tvos/export_presets.cfg scripts/release/fetch-sentry.sh scripts/release/build-ios.sh scripts/dev/install-dev.sh net/sentry_boot.gd tools/sentry_probe/probe.gd tests/test_sentry_boot.gd tests/test_release_presets.gd main.gd
git status --short # confirm: platform/tvos/addons shows the symlink removal + addons/gut only, nothing under addons/sentry
git commit -F /tmp/bh-msg.txt
  • Step 8: Prove the abort criteria don’t trip (local builds; device boot is the one on-device step)

iOS compiles and links with the addon:

Terminal window
cd /Users/chris/Claude/bullet-heaven/platform/tvos
/opt/homebrew/bin/godot --path . --rendering-method mobile --export-release "iOS" /private/tmp/bh-ios-sentry/BulletHeaven.xcodeproj > /private/tmp/bh-ios-sentry-export.log 2>&1 || tail -20 /private/tmp/bh-ios-sentry-export.log
git checkout -- project.godot
/usr/bin/grep -i "gdextension\|sentry" /private/tmp/bh-ios-sentry-export.log | head
bash ../../scripts/release/apply-ios-metrickit.sh /private/tmp/bh-ios-sentry
cd /private/tmp/bh-ios-sentry
xcodebuild -scheme BulletHeaven -configuration Release -sdk iphoneos -destination 'generic/platform=iOS' \
-derivedDataPath /private/tmp/bh-ios-sentry-dd CODE_SIGNING_ALLOWED=NO build > build.log 2>&1 || tail -30 build.log
ls /private/tmp/bh-ios-sentry-dd/Build/Products/Release-iphoneos/BulletHeaven.app/Frameworks/ | /usr/bin/grep -i sentry

Expected:

  • The export succeeds into a scratch dir, so the signed build-ios/ shell is untouched.
  • BUILD SUCCEEDED.
  • Frameworks/ lists the sentry framework(s).

If the export fails with the stock 4.7.1 template, that is criterion 2.

The tvOS pack stays clean:

Terminal window
cd /Users/chris/Claude/bullet-heaven/platform/tvos
~/Claude/godot-tvos/godot/bin/godot.macos.editor.arm64 --headless --path . --export-pack "tvOS-Dev" /private/tmp/bh-tv-sentry.pck > /private/tmp/bh-tv-sentry.log 2>&1 || tail -20 /private/tmp/bh-tv-sentry.log
git checkout -- project.godot 2>/dev/null || true
strings /private/tmp/bh-tv-sentry.pck | /usr/bin/grep -c "addons/sentry/bin" # must be 0
strings /private/tmp/bh-tv-sentry.pck | /usr/bin/grep "sentry.gdextension" | head -3 # extension_list reference?
/usr/bin/grep -i "gdextension\|sentry" /private/tmp/bh-tv-sentry.log | head

Expected:

  • The count is 0.
  • No GDExtension error in the log.
  • If sentry.gdextension still shows up (via .godot/extension_list.cfg inside the pck) [Guessing whether Godot 4.7 filters that list by exclude_filter], the runtime will try to load a missing extension on tvOS. Run the ATV check below and treat any sentry/GDExtension error line as criterion 4.

ATV boot check. This installs the Dev app on Chris’s Apple TV, the routine bh-deploy path. It needs the ATV awake; if it isn’t reachable, leave this box unticked and add the check to the Chris-gated list:

Terminal window
cd /Users/chris/Claude/bullet-heaven && bash scripts/dev/install-dev.sh atv --no-bump
xcrun devicectl device process launch --console --device 8C229574-499D-51EE-9B4F-87DEB2865DB3 digital.lumara.bulletheaven.dev 2>&1 | head -200 | /usr/bin/grep -i "gdextension\|sentry\|error" | head

Expected: no sentry/GDExtension lines. Stop the console with Ctrl-C after the menu appears; head ends it in a script. If a check trips, follow the abort path in this task’s header. Afterwards: rm -rf /private/tmp/bh-ios-sentry /private/tmp/bh-ios-sentry-dd /private/tmp/bh-tv-sentry.pck /private/tmp/bh-tv-sentry.log.

As built (2026-09-29, branch tv2/task9), for Task 11 to document:

  • Probe (stock 4.7.1, tvOS project on macOS): SentrySDK singleton present; init, close, set_tag, is_enabled present; init(configuration_callback: Callable), set_tag(key: String, value: String); SentryOptions has dsn, release, environment, send_default_pii. No name changes were needed.
  • Fetched addon (iOS + macOS libs only): 44 MB. iOS no-signing xcodebuild: BUILD SUCCEEDED, Frameworks/ gains libsentry.ios.release.framework (1.8 MB) + SentryObjC.framework (5.1 MB). tvOS-Dev pack: 0 addons/sentry/bin, no sentry.gdextension, no extension_list.cfg; full tvOS export: no sentry in the pbxproj, dylibs/ empty.
  • platform/tvos/project.godot’s trailing # comments became ;: with #, Godot swallowed the appended [sentry] section into one garbage key and auto_init=false was never set.
  • The Sentry editor plugin force-enables always_track_call_stacks in every export once the addon is fetched; tvOS gets a .tvos=false override (see G12 for iOS).
  • SentryBoot has test seams (sdk_override, os_override, dsn_path, user_dir, cache_dir, user_factory, simulate_relaunch_for_test(), reset_for_test()); the toggle tests drive main._on_diagnostics_toggled against a recording fake SDK.
  • Review round: Sentry user id = install id (ClassDB.instantiate("SentryUser") + set_user, probed); opt-out (and a boot while off) deletes sentry.dat, Cocoa’s INSTALLATION and io.sentry/ (iOS only: a Mac’s Caches dir is shared); no re-init in the launch that opted out (Cocoa caches its installation id in memory for sessions; sentry-godot exposes no auto-session-tracking option), so opt-in resumes at the next launch. .ios=false call-stack override + scripts/release/sentry-guard.sh (dsn-vs-override, ios-shell). tvOS presets also exclude sentry_dsn.json; all four exclude addons/gut/* (this removed the Cannot set object script export error from GUT’s gut_loader_the_scene.tscn).
  • Defect for the Task 11 docs: checking out a pre-Task-9 commit restores platform/tvos/addons as a symlink, which deletes the ignored addons/sentry/ dir, and un-ignores sentry_dsn.json (the .gitignore lines are gone there). Re-run fetch-sentry.sh after coming back, and keep a DSN file out of any git add on an old commit.

Task 10: dSYM verification (can Xcode Organizer symbolicate engine frames?)

Section titled “Task 10: dSYM verification (can Xcode Organizer symbolicate engine frames?)”

Files:

  • Modify: docs/architecture/dev-tools.md (new subsection under the Task 11 “Telemetry v2” section; Task 11 writes the section around it, so this task adds it as its own ### Crash symbolication (dSYMs) block at the end of the file)

Interfaces:

  • Consumes: the three existing archives:
    • platform/tvos/build-ios/BulletHeaven.xcarchive
    • platform/tvos/build/BulletHeaven.xcarchive
    • platform/tvos/build/BulletHeaven-tvos.xcarchive
    • All three have dSYMs/BulletHeaven.app.dSYM, verified 2026-09-29.
  • Produces: a recorded yes/no per archive for:
    • UUIDs match between app and dSYM.
    • An engine symbol resolves to file:line.
    • An engine symbol at least resolves to a function name.
  • No code, so no TDD cycle. The “test” is the command output, pasted into the doc.

The spec wants Organizer crash logs usable. Organizer symbolicates with the dSYM uploaded alongside the archive. The fork builds the engine template without debug info unless the template was built with debug_symbols=yes (out of scope per the spec). So function names are [Likely] and file:line is [Guessing].

  • Step 1: Run the check per archive

Same find dSYMs -path "*DWARF/*" pattern as .claude/skills/bh-appstore-release/SKILL.md:81:

Terminal window
cd /Users/chris/Claude/bullet-heaven/platform/tvos
for A in build-ios/BulletHeaven.xcarchive build/BulletHeaven.xcarchive build/BulletHeaven-tvos.xcarchive; do
echo "=== $A"
D=$(find "$A/dSYMs" -path "*DWARF/*" -type f | head -1)
B="$A/Products/Applications/BulletHeaven.app/BulletHeaven"
echo "-- uuids (must match)"; dwarfdump --uuid "$B"; dwarfdump --uuid "$D"
echo "-- engine symbols in dSYM"; nm "$D" | /usr/bin/grep -c "Main9iteration"
S=$(nm "$D" | /usr/bin/grep "__ZN4Main9iterationEv$" | awk '{print "0x"$1}' | head -1)
echo "-- Main::iteration at $S"
[ -n "$S" ] && atos -o "$D" -arch arm64 -l 0x100000000 "$S"
echo "-- compile units with line tables"; dwarfdump --debug-line "$D" 2>/dev/null | /usr/bin/grep -c "include_directories\|file_names" || true
strings "$B" | /usr/bin/grep -m1 custom_build || echo "(stock engine)"
done

Read each result:

  • If the UUIDs differ, the archive was rebuilt after the dSYM, and Organizer can’t use it.

  • atos printing Main::iteration() (in BulletHeaven) (main.cpp:NNNN) means full symbolication.

  • Main::iteration() (in BulletHeaven) + 0 means names only.

  • A raw address means no symbols. A count of 0 for Main9iteration means the engine was stripped before the dSYM was generated.

  • Step 2: Record the result in docs/architecture/dev-tools.md

Append (fill the table from the Step 1 output, verbatim values, no guesses):

### Crash symbolication (dSYMs) — checked 2026-09-29
Question: when a tester crash shows up in Xcode Organizer, will the engine frames be readable?
Checked on the last archives with `dwarfdump --uuid`, `nm` and `atos` (commands: telemetry v2
plan Task 10).
| Archive | Engine | UUIDs match | `Main::iteration` in dSYM | atos result |
|---|---|---|---|---|
| build-ios/BulletHeaven.xcarchive | (from `custom_build` line) | yes/no | count | (paste) |
| build/BulletHeaven.xcarchive | ... | ... | ... | ... |
| build/BulletHeaven-tvos.xcarchive | ... | ... | ... | ... |
What it means: (one line per outcome: full file:line / names only / nothing). If names only,
file:line needs the export template rebuilt with debug symbols, which the telemetry v2 spec left
out of scope.
Keep uploading dSYMs with every archive (release.sh does, via the xcodebuild export).

Replace every placeholder cell with the real output before committing. The plan can’t pre-fill them; this is a measurement. The committed file must not contain “yes/no”, “count”, “(paste)” or “…”. Check that with /usr/bin/grep -n "yes/no\|(paste)\|\.\.\. |" docs/architecture/dev-tools.md, which must print nothing.

  • Step 3: Commit
Terminal window
cat > /tmp/bh-msg.txt <<'EOF'
docs(dev-tools): record dSYM symbolication check for the iOS/tvOS archives
Telemetry v2 Task 10: UUID match + engine symbol + atos result per archive, so we know what an
Organizer crash log will and won't show.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gk
EOF
git add docs/architecture/dev-tools.md
git commit -F /tmp/bh-msg.txt

Task 11: Docs — dev-tools.md “Telemetry v2” section and the local bh-telemetry skill

Section titled “Task 11: Docs — dev-tools.md “Telemetry v2” section and the local bh-telemetry skill”

Files:

  • Modify: docs/architecture/dev-tools.md (new ## Telemetry v2 (2026-09-29) section, inserted directly ABOVE the ### Crash symbolication (dSYMs) block from Task 10, so that block becomes its last subsection)
  • Local only (gitignored, never git add -f): .claude/skills/bh-telemetry/SKILL.md

Interfaces:

  • Consumes: every name from Tasks 1-10. The text below uses them verbatim.

  • Produces: the orientation doc a future session reads before touching telemetry.

  • Step 1: Insert the section into docs/architecture/dev-tools.md

## Telemetry v2 (2026-09-29)
Spec: `docs/superpowers/specs/2026-09-29-telemetry-v2-design.md`. Plan (with the deviations from
the spec): `docs/superpowers/plans/2026-09-29-telemetry-v2.md`. Research that motivated it:
`docs/research/2026-09-28-telemetry-gaps-and-options.md`.
**One pipe.** Every reporter pushes onto `EventQueue` (`net/event_queue.gd`, persistent node made
in `main._ready`), never HTTP directly. `EventQueueCore` (pure) keeps pending events on disk
(`telemetry_queue.txt`, user:// or the cache dir on tvOS; line = `<id>\t<type>\t<json>`), caps
them at 1 MB (oldest dropped first), and POSTs up to 20 per request to `POST /events`. The
first flush happens straight after boot; after that it flushes every 10 s or at 20 pending,
immediately on background, run end and feedback, and backs off 10 s → 300 s on failure. A batch
is dropped only on a 2xx whose `ok+dup+bad` equals what was sent, or on a 400/413. The Worker
dedupes on the event id, so resends are safe.
**Envelope** (every event): `id, t, ts, install, launch, session, build, channel, dev, auto, d`.
`install` = `MetaState.install_id` (random, rotated on re-enable); `launch` = per process;
`session` = per run (shared by samples, hitches, run, run_end, encounters, feedback).
**Types.** `sample`, `hitch`, `run`, `encounter`, `feedback` also land in their old tables
(plus `install_id`, `launch_id`, `event_id`); every type lands in `events` (for the legacy types
`data` is NULL, since the row is in its own table). Lifecycle types: `launch` (carries
`channel_probe`, `first_launch`, OS/model), `prev_exit` (how the LAST launch ended: `crash`,
`killed_in_background`, `web_closed`, from the always-armed `exit_marker.json`), `menu`,
`tutorial_step`, `shop_buy`, `run_start`, `run_end` (`reason`: `death`, `story_complete`,
`tutorial_cap`, `quit_menu`, `dev_restart`), `background`, `foreground`, `metrickit` (iOS).
`runs` stays "banked runs only"; a quit or a kill shows up as `run_end` / `prev_exit`, not a row.
`runs.timeline_json` = level-ups (time, offered, picked), XP by enemy type, damage by weapon,
boss spawn/kill times.
**Perf fields.** `samples.render_ms` and `hitches.proc_ms` are `Performance.TIME_PROCESS`, the
whole frame's process time, NOT "our script" and NOT rendering (the old dashboard reading was
wrong). Real render cost: `samples.render_cpu_ms` (every viewport's measured render CPU time +
frame setup). `samples.gpu_ms` is NULL on Metal (tvOS/iOS) and WebGL: those renderers have no GPU
timestamps; it only fills on desktop Vulkan/D3D12/GL. Hitches: `HitchSampler` sends every first-use (`new:`) or pipeline-compile
stall, otherwise the worst 2 per 30 s, at most 200 per run.
**Querying** (from `telemetry/`, account id is not a secret):
export CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc
npx wrangler d1 execute bullet-heaven-telemetry --remote --command \
"SELECT type, COUNT(*) n FROM events WHERE ts > (strftime('%s','now')-86400)*1000 GROUP BY type"
# sessions that ended without a banked run, and why
npx wrangler d1 execute bullet-heaven-telemetry --remote --command \
"SELECT json_extract(data,'$.reason') reason, COUNT(*) FROM events WHERE type='run_end' GROUP BY 1"
# crashes / background kills reported on the next launch
npx wrangler d1 execute bullet-heaven-telemetry --remote --command \
"SELECT build, dev, json_extract(data,'$.reason'), json_extract(data,'$.state'), json_extract(data,'$.recent') FROM events WHERE type='prev_exit' ORDER BY ts DESC LIMIT 50"
**Share diagnostics.** Pause menu → "Diagnostics: On/Off" (`MetaState.share_diagnostics`,
default on, saved). Off: queue wiped, pushes become no-ops, install id cleared and dropped from
`GET /config`, Sentry closed, MetricKit files deleted unsent. On again: a fresh install id.
Privacy answers: `marketing/appstore/compliance.md`.
**MetricKit (iOS only).** `platform/tvos/patches/ios/metrickit-sink.mm` is APPENDED to the
export's `dummy.cpp` (renamed `dummy.mm`) by `scripts/release/apply-ios-metrickit.sh` (run by
`build-ios.sh` and `install-dev.sh`). It writes `<Caches>/mk-<kind>-<ms>-<rand>.json`, and
`net/metrickit_pickup.gd` sends each file as a `metrickit` event (`d.raw` = the JSON text, not
re-parsed: Godot floats would corrupt 64-bit addresses) at boot and on foreground. Not on tvOS
(MetricKit doesn't exist there).
**Sentry (iOS only, dormant).** sentry-godot 2.2.0 is fetched (sha256-pinned) into the gitignored
`platform/tvos/addons/sentry/` by `scripts/release/fetch-sentry.sh`. `[sentry] options/auto_init=false`;
`net/sentry_boot.gd` initialises only on iOS, with `platform/tvos/sentry_dsn.json` present
(gitignored, Chris supplies) and diagnostics on. The tvOS presets exclude `addons/sentry/*` (no tvOS
library upstream). A Dev iPhone build made with `--export-pack` only has Sentry if `build-ios/` last
came from a full export with the addon fetched. (Replace this paragraph with "Sentry parked: ..."
if Task 9's abort path was taken.)
**Deploy order is fixed.** D1 migration (`telemetry/migrations/2026-09-29-telemetry-v2.sql`, one
statement at a time) → `PRAGMA table_info` on all six tables → Worker deploy → smoke test. The
Worker's old routes write the new columns, so deploying first breaks every insert. A v2 client
that ships before the Worker deploy keeps its events queued (404 = retry), so nothing is lost, but
keep builds away from testers until the smoke test passes.
  • Step 2: Update the local bh-telemetry skill (not committed)

In .claude/skills/bh-telemetry/SKILL.md, directly under the # Bullet Heaven — telemetry (query + add capture) heading’s intro paragraph, add:

**Telemetry v2 (2026-09-29) changed the capture side: read `docs/architecture/dev-tools.md`
§ "Telemetry v2" first.** New capture = a new event type pushed through `EventQueue.push(type, d)`
(no new route, no new HTTPRequest). It lands in `events.data` with zero Worker changes. Only add
a legacy-style table when the data needs indexed columns. Lifecycle / crash / funnel questions:
query `events` (`type IN ('launch','prev_exit','run_end','tutorial_step','shop_buy')`), not `runs`.

Then check that the skill’s “adding new capture” section doesn’t still tell the reader to add a POST route for every new data point. If it does, prefix that section with: (Pre-v2 pattern. For new event types use EventQueue.push; see the note at the top.)

  • Step 3: Check the frontmatter, then commit and push

docs/architecture/dev-tools.md already has its title: frontmatter; the insertion is below it. Run: head -3 docs/architecture/dev-tools.md → the first line is ---.

Terminal window
cat > /tmp/bh-msg.txt <<'EOF'
docs(dev-tools): telemetry v2 orientation (queue, events, toggle, MetricKit, Sentry, deploy order)
Telemetry v2 Task 11.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y35F6KGtj5rCUArfgUg6gk
EOF
git add docs/architecture/dev-tools.md
git commit -F /tmp/bh-msg.txt
git pull --rebase && git push origin main # the project rule: push after every local merge

Chris-gated (needs Chris’s same-day OK; no task above does any of this)

Section titled “Chris-gated (needs Chris’s same-day OK; no task above does any of this)”

These are outward-facing or irreversible. An agent message is not approval. Ask Chris, get a yes in his own words for today, then run them in this order.

G1. D1 migration (live DB). Must come BEFORE G2. Each ALTER runs once; “duplicate column name” on a re-run is expected and harmless.

Terminal window
cd /Users/chris/Claude/bullet-heaven/telemetry
export CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc
/usr/bin/grep -v '^--' migrations/2026-09-29-telemetry-v2.sql | /usr/bin/grep -v '^[[:space:]]*$' | while IFS= read -r stmt; do
echo ">> $stmt"
npx wrangler d1 execute bullet-heaven-telemetry --remote --command "$stmt" || echo "!! FAILED (fine only if 'duplicate column name')"
done
for t in samples runs encounters feedback hitches events; do
echo "== $t"; npx wrangler d1 execute bullet-heaven-telemetry --remote --command "PRAGMA table_info($t)" | /usr/bin/grep -o '"name": *"[a-z_]*"' | tr '\n' ' '; echo
done

Expected:

  • samples lists install_id launch_id event_id auto gpu_ms render_cpu_ms.
  • runs lists install_id launch_id event_id timeline_json.
  • encounters, feedback and hitches list install_id launch_id event_id.
  • events exists with rts.

Stop if anything is missing.

G2. Worker deploy. Only after G1 verifies. Commit or stash anything uncommitted under telemetry/ first; a PreToolUse hook blocks a deploy with a dirty tree.

Terminal window
cd /Users/chris/Claude/bullet-heaven/telemetry && CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc npx wrangler deploy

G3. Smoke test, then clean up:

Terminal window
cd /Users/chris/Claude/bullet-heaven/telemetry
export CLOUDFLARE_ACCOUNT_ID=06f5622d4192dcefefbe0da75d766bfc
ID=$(openssl rand -hex 16); NOW=$(($(date +%s)*1000))
BODY="{\"v\":1,\"events\":[{\"id\":\"$ID\",\"t\":\"sample\",\"ts\":$NOW,\"install\":\"smoketest\",\"launch\":\"smoketest\",\"session\":\"smoketest\",\"build\":0,\"channel\":\"dev\",\"dev\":\"smoketest\",\"auto\":0,\"d\":{\"fps\":60,\"gpu_ms\":1.5}}]}"
curl -s -X POST https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/events -H 'content-type: text/plain' -d "$BODY"; echo # {"ok":1,"dup":0,"bad":0}
curl -s -X POST https://bullet-heaven-telemetry.chris-allen-06f.workers.dev/events -H 'content-type: text/plain' -d "$BODY"; echo # {"ok":0,"dup":1,"bad":0}
npx wrangler d1 execute bullet-heaven-telemetry --remote --command "SELECT type, session FROM events WHERE session='smoketest'"
npx wrangler d1 execute bullet-heaven-telemetry --remote --command "SELECT session, gpu_ms, install_id FROM samples WHERE session='smoketest'"
npx wrangler d1 execute bullet-heaven-telemetry --remote --command "DELETE FROM events WHERE session='smoketest'"
npx wrangler d1 execute bullet-heaven-telemetry --remote --command "DELETE FROM samples WHERE session='smoketest'"

Expected:

  • Exactly one events row and one samples row, with gpu_ms 1.5 and install_id smoketest.
  • Both deleted afterwards.

Then open the dashboard and check the new “gpu ms” and “process ms” headers render.

G4. App Store Connect privacy label. Apply marketing/appstore/compliance.md:

  • Device ID, Crash Data, Performance Data, Product Interaction, Other User Content.
  • All Not Linked, no tracking.

This must be done BEFORE submitting the first build that carries telemetry v2 to review. TestFlight external testing also shows the label.

G5. Privacy notice on the site (~/Claude/bullet-heaven-site, deploy only via its scripts/deploy-site.sh). Draft text for /privacy/, to merge with whatever the page already says (read it first; keep its voice; Lumara copy rules apply, so no em-dashes):

Diagnostics. Dark Cosmos sends anonymous diagnostics to help us fix crashes and slowdowns: how the game performs on your device (frame rate and timings), how a session went (how long a run lasted, how it ended, which upgrades were picked, shop purchases, tutorial progress), and whether the game closed unexpectedly last time. On iPhone and iPad this can include Apple’s crash and performance reports (MetricKit) and, if we switch it on, crash reports processed by Sentry. Any note you choose to send with “Send feedback” is included too.

We use a random install ID so we can tell sessions from the same install apart. It is made on your device, it is not your Apple ID, advertising ID or device serial, and it is not linked to your name or any account. We do not sell this data or use it for advertising or tracking.

Turning it off. Pause the game and select “Diagnostics: On” to switch it to Off. Nothing more is sent, anything waiting to be sent is deleted from your device, and the install ID is discarded. If you turn it back on, a new, unrelated ID is created.

Data is stored with Cloudflare (and Sentry, for iOS crash reports, if enabled) and kept for up to [Chris: retention period] before deletion.

The retention period is Chris’s decision; there is no retention job yet (spec: out of scope). Say so if he asks.

G6. On-device MetricKit check (iPhone Dev app). After a full iOS export (build-ios.sh, or a full export followed by install-dev.sh iphone):

  • Launch, background, and relaunch the next day.
  • xcrun devicectl device info files --device 12FDF0CC-72E2-581B-B306-714C6E2275AC --domain-type appDataContainer --domain-identifier digital.lumara.bulletheaven.dev --subdirectory Library/Caches should show mk-*.json appearing and then being removed.
  • A metrickit row should show up in events.

This confirms OS.get_cache_dir() == NSCachesDirectory on iOS (currently [Likely]).

G7. Sentry project. Only if Chris wants Sentry live:

  1. He creates the project (free Developer plan) and writes platform/tvos/sentry_dsn.json as {"dsn": "<value>"}. It is gitignored. The value never goes into chat or a commit.
  2. Then a full iOS export.
  3. Remove settings/gdscript/always_track_call_stacks.ios=false from platform/tvos/project.godot (Sentry needs script call stacks). scripts/release/sentry-guard.sh dsn-vs-override, run by build-ios.sh and install-dev.sh (iOS), refuses a DSN while that override is present, and test_release_presets asserts the override exists iff there is no DSN file.
  4. Confirm Sentry works: temporarily force a crash on the Dev app, and check it lands in Sentry with release dark-cosmos@<BUILD> and user id = the app’s install id.
  5. G13 below, on the same device.

G8. Skills in git. Tasks 8 and 11 edit .claude/skills/*/SKILL.md, which is globally gitignored. Ask Chris whether he wants them force-added (git add -f), or left local as today.

G9. TestFlight gate. Don’t upload a telemetry v2 build to TestFlight/App Store before G1-G4 are done. Also check the dashboard shows /events traffic from a Dev-app session first.

G10. Perf units on device (Dev app, Apple TV). After a Dev-app session on the ATV reaches the live Worker, check one sample: SELECT fps, render_ms, render_cpu_ms, gpu_ms FROM samples WHERE dev LIKE 'tvOS%' ORDER BY ts DESC LIMIT 5. Expect render_cpu_ms roughly 0.5-20 (milliseconds; a value in the thousands means the unit is wrong) and gpu_ms NULL (Metal has no timestamp queries: expected, not a bug).

G11. Task 9 abort criterion 4: Dev app boot on the ATV with the addon fetched. Not run by the Task 9 agent (no device installs). In the main checkout after merging Task 9 (so platform/tvos/addons/sentry/ exists, i.e. after bash scripts/release/fetch-sentry.sh): bash scripts/dev/install-dev.sh atv --no-bump, then xcrun devicectl device process launch --console --device 8C229574-499D-51EE-9B4F-87DEB2865DB3 digital.lumara.bulletheaven.dev 2>&1 | head -200 | /usr/bin/grep -i "gdextension\|sentry\|error" | head. Expected: no sentry/GDExtension lines (the agent’s tvOS-Dev pack held no addons/sentry/bin, no sentry.gdextension and no extension_list.cfg, and a full tvOS export’s pbxproj had no sentry reference, so [Likely] clean). A hit = Task 9 abort path.

G12. iOS call-stack tracking (Task 9 finding; decided). sentry-godot’s editor plugin force-sets debug/settings/gdscript/always_track_call_stacks=true in every export once the addon is fetched. Both .tvos=false and .ios=false overrides are in platform/tvos/project.godot, so a dormant Sentry costs no script time (decided in the Task 9 review). Side effect: the SDK logs ERROR: Sentry: Please enable always_track_call_stacks once per iOS launch while dormant (harmless). Enabling Sentry = remove the .ios line (G7 step 3).

G13. Sentry identity on device (Task 9, only once a DSN exists). Verified from source only (sentry-godot 2.2.0 / sentry-cocoa 9.28.0): SentryBoot sets the Sentry user id to the install id; opt-out closes Sentry and deletes <user dir>/sentry.dat, <Caches>/INSTALLATION and <Caches>/io.sentry/; opt-in resumes at the next launch. On the Dev iPhone: opt out, then xcrun devicectl device info files … --domain-type appDataContainer --domain-identifier digital.lumara.bulletheaven.dev --subdirectory Library/Caches shows no INSTALLATION / io.sentry; relaunch, opt in, relaunch, force a crash; in Sentry the event’s user id and the session’s distinct id are the NEW install id / a fresh installation id, not the pre-opt-out ones. Also confirm nothing reaches Sentry between opt-out and the next opt-in (auto session tracking can’t be turned off through sentry-godot, and close() may flush a final session update).


  • Run with superpowers:subagent-driven-development, one task per implementer (bh-implementer, model: "opus"), each in its own worktree from main, with bh-reviewer before every merge. Tasks are strictly ordered 1 → 11. Each depends on names produced by the one before (see the Interfaces blocks).
  • After every task: bash scripts/gate.sh green, the seed-1234 baseline in tests/test_determinism_survival.gd unchanged (never re-pinned for this work), and merge + git push origin main straight away (project rule).
  • Tasks 8 and 9 need the Mac’s Xcode, and Task 9 Step 8 needs the Apple TV awake. Run those two in the main checkout, not a remote agent.
  • Task 10 is measurement only. Its table must hold real output.
  • Nothing in “Chris-gated” is done by an agent without Chris’s same-day yes.
  • UI change (Task 7): run bh-visual-qa on the pause menu at iPhone, iPad and ATV sizes before merging.

Self-review (run 2026-09-29 against the spec)

Section titled “Self-review (run 2026-09-29 against the spec)”

Spec coverage:

Spec item Where
/events batch route, dedupe, limits, feedback rate limit, old routes kept Task 1
Persistent on-disk queue, envelope, ids, flush/backoff, tvOS cache fallback Task 2
All reporters through the queue; one session id; install/launch ids; auto on samples Task 3
Always-armed exit marker, prev_exit, lifecycle/funnel events, run_start/run_end reasons, shop_buy Task 4
Mid-run timeline: level-ups offered/picked with time, XP by type, damage by weapon, boss TTK Task 5
GPU/render-CPU time; hitch sampler replacing first-40; mislabelled perf fixed Task 6
Share-diagnostics toggle (pause menu, default on, persisted), queue wipe, id rotation, /config without install, compliance.md Task 7
MetricKit iOS (sink, pastPayloads, pickup at boot/foreground, README + skills, xcodebuild no-signing check) Task 8
Sentry iOS-only, dormant, tvOS excluded, send_default_pii off, release/environment/install tag, park-if-broken Task 9
dSYMs usable for Organizer Task 10
Docs Task 11
Deploy order, smoke test, ASC label, privacy-notice draft Chris-gated G1-G5

No spec item is without a task. Out-of-scope items stay out: retention dashboards, Analytics Engine, Sentry on web/tvOS, engine debug-symbol template, deleting old routes.

Placeholder scan:

  • Task 10’s table is a measurement template. It carries an explicit grep that fails if a placeholder is committed.
  • G5 carries one bracketed retention period that only Chris can decide.
  • Nothing else is TBD.

Type/name consistency: checked these across tasks:

  • EventQueue.setup(install_id, share), set_enabled(on, install_id), is_enabled(), push(), flush_now(), launch_id(), enable_for_test(), muted.
  • EventQueueCore.pending_count(), pending_events(), BATCH_MAX_BYTES.
  • LifecycleTelemetry.boot(first_launch, share), on_foreground(), pick_up_metrickit().
  • Telemetry.setup(sim, queue), flush_held(), render_times().
  • HitchSampler.offer/poll/drain/sent.
  • MetaStore.set_share_diagnostics, RemoteConfig.fetch_url/set_install_id.
  • PauseMenu.share_diagnostics/diagnostics_toggled/_diag_btn.
  • MetricKitPickup.pick_up(dir, queue), SentryBoot.apply(share, install_id), main._on_diagnostics_toggled(on).

Fixed during review:

  • The Task 5 test used sim.tick(...). It now uses tick_single.
  • The Task 2 cap test used integer division. It now uses float maths.
  • Deviation 2 said “any 4xx drops”. It now says 400/413 only, so a pre-deploy 404 retries.
  • The MetricKit design parsed the payload JSON. It now sends raw text, because Godot’s float parsing corrupts 64-bit addresses.
  • The file-structure row for dummy.mm now names the appended metrickit-sink.mm.

Known risks left open (flag, don’t fix):

  • A burst of 4+ large MetricKit payloads (≤ 200 KB each) can hit the 1 MB queue cap and evict older events.
  • OS.get_cache_dir() vs NSCachesDirectory is unproven until G6.
  • Whether Godot 4.7 keeps an excluded GDExtension in the tvOS pck’s extension_list.cfg is unknown until Task 9 Step 8.
  • sentry-godot’s init(Callable) and SentryOptions property names are unproven until the Task 9 Step 4 probe.
  • The .linker_option directive is unproven until the Task 8 Step 7 otool check. A fallback is given.