Skip to content

v0.4.0 (2026-07-07)

v0.4.0 is the largest release since the project was structured. Two things happen at once.

First, asset generation stops being a 2D pixel-art test and becomes a multi-medium discipline. Where v0.3.2 shipped a self-contained desktop app over a benchmark that drew small sprites, a case can now ask a model to sculpt an opaque-voxel model, extract a meshed surface three different ways (marching cubes, surface nets, dual contouring), rig a skinned character on a real skeleton, paint a high-resolution interface asset or a tileable PBR material, author a particle system, or synthesize a sound effect or a piece of music — all through the same drive-a-tool-through-many-small-steps design that makes the 2D kinds work, and each with a runtime that lets a real game consume what a run produced.

Second, the last off-cluster pieces of the pipeline come onto the cluster. A run’s public release is now a per-publish Kubernetes Job instead of a step driven from an operator’s own machine, the web console ships as a pod, and with those gone azure-prod comes online as a real, VPN-reachable production environment. Alongside, a broad wave of new game and asset test cases, three new models, and a rebuilt catalog UX.

This remains pre-1.0 software, built first for the people working on the project.

The standalone “push” run operation is gone

Section titled “The standalone “push” run operation is gone”

The run lifecycle drops from three operator steps to two: review → publish. “Push” was a vestige of the long-lived-worker model — an explicit step that released a finished run and stored its record before review. In the in-cluster ephemeral-pod model there is nothing for it to do: when the driver finishes a run it reports the produced record to the backend automatically (stored privately) and uploads the run tree to the artifact service, where the build is playable for review immediately. By v0.3.1 every push surface had already been reduced to a no-op; v0.4.0 removes the dead surface end to end:

  • The tcab push subcommand is removed from the CLI, along with the “Push run” button in both consoles. Review and publish no longer gate on a prior push.
  • The backend’s POST /runs push route is removed (the storage primitive the driver uses to record a finished run stays). The core’s Publisher::push family, BackendClient::push_run, and the PushRequest/PushResponse wire types and their generated contract schema are gone; the release input type is renamed PushRequestReleaseRequest.
  • The docs now describe a run as “stored when produced” rather than pushed — see results, terminology, and architecture. tcab publish remains a solo convenience that self-reviews and publishes in one shot.

The arena’s separate “pushed-run controller” concept is unrelated and unchanged.

Asset generation learns a third dimension: voxel-model and voxel-animation

Section titled “Asset generation learns a third dimension: voxel-model and voxel-animation”

A case declares one of two new asset_kinds to produce a 3D asset instead of a 2D image (see Manifests):

  • voxel-model — a static opaque-RGB voxel model, sculpted with the new voxel binary.
  • voxel-animation — a rigged, animated model, sculpted per part with the new voxel-anim binary (a required --part <name> on every op) plus rig subcommands.

Both replace the [canvas] table with a [voxel] table (width/height/depth and a preview background); the volume is opaque #rrggbb with no alpha and starts empty. The binaries share one voxel-and-raster library with the validator — including a fixed, integer-only isometric rasterizer that serves both the in-container preview and the post-run regeneration, so cheat-divergence is as enforceable in 3D as it is for a sprite. See The voxel binaries.

The rig: model-invented parts and joints, with a game-facing animation contract

Section titled “The rig: model-invented parts and joints, with a game-facing animation contract”

A voxel-animation case’s [model] table declares only the required animations — named F-curve timelines a game plays back (a walk, a recoil, an idle), each with a loop and auto_play flag. The rig itself is model-invented: the model devises whatever parts (a parent/child hierarchy with attachment pivots) and joints (rotations or translations, each caller-driven for a game to pose at runtime or auto-play from a model-authored clip) it needs to carry those animations, creating them at run time with define-part / define-joint. This makes the case measure whether a model can work out the pieces a subject needs — not whether it can follow a prescribed skeleton. The validator derives the parts to score from the produced rig.json and reconciles it against the contract: a missing or empty required animation is a recorded, zero-scored contract gap, not a crash. See Voxel models and rigs and Evaluation. A separate walker-rigging guide covers building legged locomotion as in-place cycles.

Meshed models: marching cubes, surface nets, and dual contouring

Section titled “Meshed models: marching cubes, surface nets, and dual contouring”

Beyond the cube kinds, a case can ask for a meshed asset — a model sculpts a signed-distance field with a shared CSG vocabulary (add-/subtract- sphere/box/ellipsoid/cylinder, soft-min --blend, replace-color, mirror, translate, copy) and the environment extracts it to a real triangle mesh. The extraction algorithm is fixed by the asset_kind, and each gives the surface a different character:

  • mc-model / mc-animationmarching cubes: a coarse grid, low-poly and chunky-faceted.
  • sn-model / sn-animationsurface nets: a medium grid, smooth and uniformly dense.
  • dc-model / dc-animationdual contouring: a fine grid solved per cell with a quadratic error function, high-fidelity and edge-preserving (with a per-primitive --sharp / --smooth tag exposed only for dc).

The static kinds use the mc / sn / dc binaries; the animated kinds use the -anim binaries and carry the same model-invented parts/joints rig as voxel-animation. After extraction every binary runs a quadric-error-metric decimation pass that collapses an edge only where it provably preserves watertight 2-manifold topology and never crosses a color boundary or a sharp feature. Meshing is not regenerated: the mesher runs once and the emitted geometry is authoritative — the validator decodes it and checks the rig contract. Rendering is an on-request render command (not automatic after every op), and previews come from a shared wgpu renderer running on Mesa lavapipe (software Vulkan, headless, arch-agnostic). See The meshing binaries.

Three further kinds — mc-skinned, sn-skinned, dc-skinned — produce a single continuous mesh bound to a skeleton and deformed by linear-blend skinning, rather than separate parts posed about pivots. There is no --part flag and no static skinned kind: a skinned model is one whole-body field, and every skinned model is inherently animated. On top of the meshing vocabulary the -skin binaries add a bone layer (define-bone, set-bone, define-joint, optional paint-weight), and render derives automatic bone-heat skin weights (a deterministic function of the mesh and skeleton, capped at four influences per vertex and normalized). The emitted .glb carries JOINTS_0 / WEIGHTS_0, a glTF skin with inverse-bind matrices, and the bone hierarchy; a first-person viewmodel is just a partial-body skinned rig with a weapon on an empty socket bone. See The skinned binaries.

Two new 2D kinds: high-resolution UI and PBR materials

Section titled “Two new 2D kinds: high-resolution UI and PBR materials”

Two kinds bring the 2D side up to large, high-resolution work:

  • ui produces a high-resolution interface asset — a large RGBA canvas (typically 256–2048 px) painted with a full layer stack, blend modes, brushes, gradients, masks, filters, and layer effects (bevel/glow/shadow/stroke) through the paint binary, plus a companion ui binary for crisp composition (rects, text with baked fonts, nine-slice). A ui kit emits one flattened PNG per element and a ui.json describing the elements, their nine-slice insets, and the atlas.
  • material produces a tileable PBR material — per-channel maps (base color, normal, roughness, metallic, AO, emissive) authored with a texture binary (the paint vocabulary restricted to one square toroidally-wrapping map, plus procedural noise/pattern/warp) and a pbr binary (bake-normal/bake-ao/bake-curvature from a height map, assemble, render). The preview projects the material triplanar onto a sphere/cube/cylinder/plane with no UVs.

Neither kind is regenerated: the emitted PNGs and material.json / ui.json are authoritative, judged on their well-formedness plus a reviewer’s assessment of the rendered result. See Evaluation.

A particle-2d or particle-3d case produces a particle system, not a still — a model authors emitters, forces, per-particle F-curves, and sub-emitters (add-emitter, set-forces, set-particle, add-subemitter, set-timeline) that emit a system.json. The effect is simulated live rather than baked, so it varies play to play; the validator judges the emitted system, not any single frame. A new @test-cabinet/particle-runtime package (mirroring voxel-runtime) splits into a pure-core simulator with a three billboard binding and a canvas 2D binding, so the review UI, the live monitor, and a real game all simulate the effect the same way. See The particle binaries.

Three kinds bring sound into the benchmark, each emitting a rendered PCM clip.wav:

  • sfx-synth — pure procedural synthesis: a modular voice/envelope/filter/effects op graph, no samples.
  • sfx-sample — the synth vocabulary plus a baked sample library to mix (weapon, naval, and footstep SFX).
  • music — a symbolic sequencer (tempo, time signature, tracks, notes) that renders both a clip.wav and a portable clip.mid score, playing real sampled instrument banks.

Audio is deterministic and regenerated from the recorded actions — synthesis noise is fixed-seed and sample mixing is a pure function of the placed layers — so the op log stays authoritative; the validator confirms the clip is well-formed, within the [audio] format, no longer than max_duration_ms, and not silent. A reviewer plays the clip against the brief with its waveform and spectrogram (and, for music, a piano-roll) shown alongside. Sample and instrument packs live in a private Cloudflare R2 bucket rather than the repo, pinned into each audio image at build time by digest through a short-lived presigned URL (combat-core for SFX, gm-lite for music); adding or updating a pack is a new version plus an image rebuild, never an in-place edit. See The audio binaries and Publishing an audio sample pack.

Runtimes to consume a produced asset, shipped into a game with packages

Section titled “Runtimes to consume a produced asset, shipped into a game with packages”

Several of the new kinds produce not a finished picture but data that needs a runtime to play or pose it — a particle system.json, a voxel or meshed rig. A new @test-cabinet/voxel-runtime package (pure-core posing math plus a three binding) poses a produced rig for both the review viewer and real games, and voxel-runtime grows a framework-agnostic mesh builder and a standalone scripts/voxel-to-gltf.mjs converter that turns a run’s rig.json + per-part .glb into a standard glTF 2.0 / GLB — one culled, vertex-colored mesh per part, a node hierarchy matching the part tree, and one baked animation per case-authored clip. See voxel-runtime.

To close the loop, an end-to-end case can now declare a packages key — e.g. packages = ["@test-cabinet/particle-runtime"] — so a built game can import a Test Cabinet runtime library to consume a produced asset instead of reimplementing the runtime from a schema. The shippable set is an allowlist; at seed time the declared libraries are vendored into the run repo under .tcab/packages/ and committed, and the case ships a workspace package.json that depends on each via an in-repo relative file: path (validated at resolution). Because the dependency lives inside the repo, it resolves the same wherever the produced tree goes — the run container, the validation host, and any clone of the published source repo — so a packages game both validates and stays playable/buildable after release, with no absolute path to break. The first consumer is the Spectra case, which loads its produced spectra-burst system.json as a drone-pop effect. See Packages.

The console renders every asset kind — live and after the run

Section titled “The console renders every asset kind — live and after the run”

The UI library gains a lazy-loaded VoxelViewer (a React Three Fiber canvas mounting the runtime’s VoxelRig): a voxel-model auto-rotates; a voxel-animation gives one orbit-drag viewer per animation with a range control per caller joint and playback of each auto-play joint, and a [[review_item]] can name the caller joints it is about so the UI surfaces exactly that control beside the item. Where WebGL is unavailable or reduced motion is requested it falls back to the regenerated preview PNG. Post-run viewers gain expand-to-fullscreen.

The live run monitor now shows work as it is produced, over one backward-compatible typed-body channel that streams each asset’s editable source after its preview PNG:

  • Voxel / meshed runs rebuild in 3D and rotate as they are sculpted, with a “Scene” view (every part assembled at rest) and a per-part “Model” view.
  • Particle runs mount the live simulator to play the effect as it is authored.
  • Skinned runs decode the streamed rig and auto-play the first animation so the skin deforms live.
  • Audio runs play the streamed clip in an <audio> element beside the waveform.

A broader sculpting vocabulary and compound joints

Section titled “A broader sculpting vocabulary and compound joints”

Both voxel binaries gained five operations for a more complete toolkit — fill-cylinder, fill-ellipsoid, replace-color, translate, and copy-box (all clip at the volume edge and replay deterministically). A voxel-animation joint can also carry a fixed compound mount — an offset (translation) and an orient (Euler rotation about the pivot) applied in addition to its driven motion, so a component can be mounted at a custom rotation and translation; a joint with an empty driven range but a non-zero mount is a purely static attachment. An all-zero mount is omitted, so rigs without one are unchanged.

Geometry is stored as binary glTF, never JSON

Section titled “Geometry is stored as binary glTF, never JSON”

Following the principle that bulk numeric data must be binary and JSON is reserved for metadata, a voxel/meshed run stores its geometry as binary glTF (.glb) — one part per file (meshes/{part}.glb), or a single mesh.glb for a static or skinned model — with rig.json carrying only the rig contract. The intermediate voxels.json and mesh.json payloads are gone; the .glb is the sole geometry, read directly by the TypeScript runtime, the review UI, the exporter, and the validator.

Variant files, per-variant domains, and per-variant volumes

Section titled “Variant files, per-variant domains, and per-variant volumes”

The manifest format for variants was reworked across every case. Variants move out of inline [[variant]] tables into their own variants/<slug>.toml files, declared by an ordered root-level variants = [...] list (the first entry is the default); inline variants are no longer supported. A variant may declare its own scoring [[domain]]s — a case declares the common domains every variant is rated on, and a run is scored against common + its variant’s own — and a voxel variant may declare its own [voxel] table that overrides the case’s for that variant’s runs. Prompt and spec templates gain a {{voxel}} context (width/height/depth and maxX/maxY/maxZ) so a brief states its volume from one source of truth, and a [[spec]] dest now defaults to its source (with .hbs stripped). A shared quality directive is prepended to every asset-generation prompt.

Every test-case version now declares a required changelog.md recording what changed in it, threaded through the whole data contract (core resolve → backend store/ingest → API and snapshot → generated TS/JSON-Schema → both frontends). The catalog aggregates every version’s entry into a new “Changelog” tab on the case detail page, newest-first, and the console now sorts versions newest-first.

An asset-generation run’s Verdict tab can download a GIF of a sprite-sheet sequence or a voxel animation. The sprite-sheet path reuses the exact frames the player draws, upscaled nearest-neighbor and quantized with gifenc preserving the transparent background; the voxel path bakes an autoplaying clip on a throwaway offscreen three.js renderer that mirrors the preview’s camera and lighting, stepping the rig deterministically via a new VoxelRig.seek(timeMs). Both share a reusable GifDownloadButton that works in the web console and the desktop webview.

Section titled “Proof clips are captured as .webm, transcoded to .mp4 for the gallery”

An end-to-end case’s video proof is now captured as the .webm Playwright records natively, so a run writes its clip with no in-container conversion. The public gallery transcodes each clip to H.264 .mp4 with ffmpeg when it builds the snapshot (webm/VP8 does not play on iOS/Safari), rewriting the moov atom to the front for progressive playback and falling back to the raw webm if a transcode fails; the live console serves the raw .webm directly. A proof dest now accepts webm in addition to mp4.

Publishing is an asynchronous, in-cluster Job

Section titled “Publishing is an asynchronous, in-cluster Job”

POST /runs/{id}/publish no longer flips a run public synchronously. It now gates and enqueues — checking the run is a completed, non-infrastructure-failure run carrying at least one review, inserting a publish_job row with a per-job token, and returning 202 Accepted with a live URL — and the release runs entirely in the cluster, mirroring how a run executes:

  1. The dispatcher drains the publish queue each tick (after the run queue), claiming a job over a service-token route.
  2. It builds one minimal tcab-publisher Job per claim — no sandbox, no subscription volume, no ServiceAccount (the publisher is HTTP-only and never creates pods).
  3. The publisher pod (no local checkout) downloads the run’s source tree from the artifact service’s GET /runs/{id}/tree.tar, performs the GitHub-repo creation and wrangler pages deploy, and streams progress lines back to the backend.
  4. On success the backend patches the run’s links, flips it published, and regenerates the public snapshot; on failure it records the reason.

tcab publish and the console both consume that live stream instead of blocking on a synchronous call. The new tcab-publisher crate (crates/publisher/) builds to its own image that drops the driver’s Playwright/Chromium layer (it renders nothing). See results and artifacts.

azure-prod is a reachable production environment

Section titled “azure-prod is a reachable production environment”

With the last off-cluster pieces gone, several parts land together to make the Azure prod overlay a real environment a VPN client can reach and an operator can keep running:

  • A private, never-public ingress. A reusable internal-ingress component exposes the console and the backend services over a VPN-only internal load balancer under a dedicated tcab.testcabinet.ai subdomain, with TLS issued automatically by cert-manager via a Let’s Encrypt DNS-01 challenge through Cloudflare. Only the A records are private, so the subdomain does not shadow the public gallery and docs.
  • All secrets from Azure Key Vault. A keyvault-csi component mirrors vault secrets into native Kubernetes Secrets, authenticated by Azure Workload Identity; auto-rotation reconciles a changed value on its own, which matters because the harness subscription tokens (Claude Code’s expires ~daily) are uploaded to the vault and mounted read-only into each driver Job.
  • A self-healing catalog. Because the managed-Postgres overlay gives the backend an ephemeral def store, catalog ingest runs as a one-shot sidecar in the backend pod that clones the repo and POSTs /ingest over localhost on start; catalog changes are published on demand with scripts/reingest-prod.sh via az aks command invoke, no local kubeconfig needed.

See deployment.

The web console ships in-cluster everywhere

Section titled “The web console ships in-cluster everywhere”

apps/web is now packaged as a static nginx image configured at runtime — an entrypoint renders /config.js from TCAB_WEB_BACKEND_URL / TCAB_WEB_AUTH_URL, so one environment-agnostic image per git-sha is pointed at each environment by env var. A reusable Deployment+Service is nested into both the local and prod overlays. Locally, make local-up now brings the entire UI up in the cluster and forwards it — there is no separate npm run dev and no VITE_BACKEND_URL to set. See Running the local service stack.

Run-container images are pinnable by git-sha and multi-arch

Section titled “Run-container images are pinnable by git-sha and multi-arch”

The run-container images are now published multi-arch (linux/amd64 + linux/arm64, each built on a native runner and fused into a manifest list) — the deployments run on arm64 nodes, where an amd64-only image made every run pod die with exec format error. CI also tags an immutable :<git-sha> manifest list beside :latest, and because run-container images aren’t Kubernetes image: fields (the driver resolves them at runtime), the dispatcher forwards TCAB_CONTAINER_TAG and the per-image overrides into every driver Job, so a cluster can pin or roll back its run images on the same staging → prod sha flow as the service images. A new guide documents the rollout.

A force re-ingest used to rebuild a version in place, leaving it manifest-less for the seconds-to-minutes the reference mockups take to render, so any run resolving that version mid-re-ingest got a 404 "is not ingested". Each version is now built in a staging dir and swapped into place with a rename, so a concurrent resolve always sees either the old or the new version, never a half-built tree — which is what makes the on-demand prod reingest safe to run while runs execute. A companion optimization only re-ingests test cases changed since the last run.

The run review UI is rebuilt around individual reviews

Section titled “The run review UI is rebuilt around individual reviews”

A run’s individual reviews are now first-class, linkable surfaces instead of being collapsed into one aggregate verdict. The Verdict tab still shows the aggregate verdict, but beneath it a review list shows each review as a compact, clickable card that opens its own page at /runs/:runId/reviews/:reviewerId; the Edit control moves onto a reviewer’s own review page.

A queued or running run can be canceled end to end. POST /jobs/{id}/cancel (gated on the launching account) atomically moves a non-terminal job to canceled and closes its live stream so every watching monitor ends at once; the driver polls its own job state and, on canceled, drops the in-flight harness exec and tears down its sandbox (under Kubernetes deleting the sandbox pod found by a tcab.dev/job-id label) without reporting. A “Kill run” control appears in the live monitor for anyone with execution rights.

Sortable, configurable, and resizable catalog tables

Section titled “Sortable, configurable, and resizable catalog tables”

The run-log and models tables became descriptor-driven, so their columns can be sorted (click a header to cycle default → ascending → descending, persisted per scope and applied across the whole run history before paging), shown or hidden (the run log gains optional Timestamp, Category, and Duration columns via a picker), and resized (drag a column edge; widths persist to localStorage, keyed by column id and floored at the header label width).

The catalog adds Claude Sonnet 5 (Anthropic), Devstral 2512 (Mistral, a coding-specialized model), and Mistral Medium 3.5 (Mistral).

Local development runs the whole stack inside the devcontainer against the host Docker daemon, and against it a large set of new cases landed:

  • New end-to-end games, including Siege (the first 3D/voxel FPS last-stand case), Caldera (a hex-terrain tower defense with a water→steam fluid sim), Thunderhead (a multi-faction fleet-command case), Sunfront (a tug-of-war RTS with the Duneforged voxel asset family), Floe, Wireworm / Fireworm, and Shatter.
    • Caldera, Siege, Thunderhead, and Sunfront should be considered pending tests, not tests ready for use. Their assets still need to be generated by asset generation test cases, and the specs still need to be revised to address issues that will be obvious once a first implementation is created.
  • The first case for each new asset kind — UI, material, skinned, particle, and audio — alongside the meshing sample cases and the Sunfront/Aegis 3D families.
  • Existing “inspired-by” slugs were renamed to Test Cabinet names (Cascade, Carom, Coil, Fathom, Lattice, Meltdown), and end-to-end difficulty was recalibrated by dimension.

New authoring guides cover every new kind (voxel models and animations, meshed and skinned models, UI, materials, particles, and audio); the authoring and variant procedures now live in the docs site rather than in repo skills, with a single authoring-test-cases skill routing to the right guide.

Runs queue for capacity instead of failing

Section titled “Runs queue for capacity instead of failing”

When the cluster had no free capacity, a sandbox pod sat Pending and the driver failed the run after a single 180s deadline — reporting a healthy run merely waiting its turn as an infrastructure failure. The pod wait is now two phases: a scheduling wait (until the pod is bound to a node) bounded only by an opt-in timeout that defaults to waiting forever, so a busy cluster makes runs queue; and the existing tight startup timeout once scheduled, so a genuinely broken pod still fails fast. Time spent queued is subtracted from the run’s recorded duration. See Kubernetes.

Run-artifact collection survives a torn exec channel

Section titled “Run-artifact collection survives a torn exec channel”

The driver collects a finished run’s tree by streaming tar -c /work over the kube exec WebSocket and reading the exit code from the channel’s terminating Status frame. When a managed API-server tunnel severed that long-lived stream, no Status arrived, so the exit came back as -1 even though the tree was intact — and because the dispatcher never retries a driver Job, a single transport blip permanently failed an otherwise-complete run. Collection (a read-only tar) is now retried up to four times with exponential backoff.

The new publish path hit several real-world failures, now handled: GitHub post-create permission lag (repo creation and the push are now separate steps, the push retries with backoff, and release_code is idempotent); opaque deploy failures (wrangler diagnostics are now drawn from both stdout and stderr); and Cloudflare account discovery (the Job now provides CLOUDFLARE_ACCOUNT_ID so wrangler skips discovery).

The base NetworkPolicy admitted the auth service from the dispatcher, driver, artifacts, and arena — but not from the backend itself, which verifies every bearer token in-cluster. Under default-deny ingress that verify call timed out and run creation failed with a 502. The policy now includes the backend as an allowed source.

delete_run removed the run and its cascading reviews and links but left the run-queue and publish-queue rows behind — both reference the run by a plain column with no foreign key, so nothing cascaded. Both queue rows are now deleted in the same transaction.

In-progress runs reconcile without a refresh

Section titled “In-progress runs reconcile without a refresh”

The web console now reconciles in-progress runs so a completion surfaces on its own, without a manual refresh.

  • The voxel rig reviewer was reworked (mode switch above the split so it stops overflowing the sidebar, real joint counts, geometry stats, scrolling), the preview was enlarged and framed to fill the view instead of half of it, and it no longer vanishes under StrictMode. Positive pitch now elevates a forward part, and the caller-joint slider no longer pops a validity bubble.
  • Particle previews frame and scroll/zoom correctly.
  • The run-detail pages no longer crash on the static public gallery (RunDeleteControl is now gated at its mount site), review cards drop the global hover underline, the active tab label regains its readability halo, and the console tabs show the cabinet favicon.
  • Model prices show four decimals only for sub-dollar USD figures, and the openrouter/ prefix is stripped when processing model ids.