Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
110 changes: 110 additions & 0 deletions .claude/skills/gba-dsl-game/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
name: gba-dsl-game
description: Build a GBA game as a TypeScript DSL + partial-evaluation compiler + fixed C runtime, the way aot/ and cine/ do it. Use when creating a new GBA title, a new game DSL package in this repo, or extending an existing one (new ops, scenes, modes), and when debugging a ROM headlessly with the mGBA harness.
---

# GBA DSL game workflow

This repo ships GBA games as **DSL packages**: a TypeScript authoring surface whose
declaration zone runs at build time, plus `cue(function* () { ... })` /
`script(...)` bodies that are **never executed** — their AST is lowered to bytecode
for a small VM inside a fixed C runtime. Proven twice: `aot/` (tile RPGs,
3 consoles) and `cine/` (cinematic montage films, GBA-only). Read one of them
before starting; `cine/` is the smaller, more modern reference.

## Architecture (copy this shape)

```
<pkg>/spec/<pkg>.ts THE single source of truth: opcode table, tween/FX targets,
waiting states, VRAM budget constants, debug-block offsets.
<pkg>/spec/gen-c.ts emits runtime/<pkg>_gen.h from the spec. TS and C never
drift because both read the same table.
<pkg>/dsl/index.ts defineX() factories + residual op stubs (return plain
tagged objects; no logic).
<pkg>/compiler/ evaluate → residualize → assets → emit gen_data.c → rom
<pkg>/runtime/ fixed C files: crt0.s, gba.h, gba.ld, irq.c, cue/script VM,
fx compositor, caption/dialog UI, obj.c, sfx
<pkg>/test/e2e.ts headless mGBA playthrough with bus-level assertions
```

Build = `bun compiler/cli.ts build <game>.ts` → evaluate the module (Bun temp-file
trick: write `entry.__<pkg>.<pid>.<n>.mjs` next to the entry, import, delete),
residualize generator ASTs to bytecode, quantize/tile art, emit one `gen_data.c`,
compile ~12 C files with `arm-none-eabi-gcc`, patch the GBA header (BIOS logo
bitmap + complement checksum) so the ROM is flashcart-ready.

## Non-negotiable sharp edges (each cost real debugging time)

1. **crt0 must end with `msr cpsr_c, #0x5F`** (system mode, IRQ *enabled*).
aot's crt0 uses 0xDF (IRQ masked at the CPU) because aot never uses
interrupts; any interrupt-driven runtime copied from it hangs on the first
frame wait with a black-ish screen and a frame counter stuck at 0.
2. **Namespace generated debug macros.** Emit offsets as `DBGO_*` and values as
`*_VAL`. A `#define DBG_MAGIC 0` (offset) silently clobbers the magic *value*
macro and every debug read returns garbage.
3. **Partition the glyph-slot ring.** Typewriter text streams Unifont halfcells
into a VRAM slot ring. Any caption that must PERSIST (chip/topbar style)
needs a private slot range sized to its column limit; if it shares the ring,
later dialog reuses its tiles and the persistent caption turns to mojibake.
4. **OBJ priority vs UI BG.** Caption/dialog BG0 runs at priority 0; scene
sprites at priority 1 are *correctly* hidden behind text boxes. Author around
it: keep actor beats above the text rows or clear captions first.
5. **Feet, not origin.** Characters read as "standing" only when sprite bottom
sits on the ground line: `y = ground_row*8 - sprite_h`. Floating characters
are the most common visual bug in generated scenes.
6. **`.iwram.text` must be in the .data LMA copy region** of the linker script,
and hot ISR code marked with an IWRAM_CODE attribute (`section(".iwram.text"),
long_call, target("arm")`). HBlank work in ROM is too slow and races the
scanline.
7. **Non-ASCII glyphs always take the fullwidth 2-cell path**, even codepoints
Unifont calls halfwidth (e.g. U+00B7), else tokenizer/interner disagree and
you get "glyph not interned" at build time. English-only text is all-ASCII
halfcells — nothing special needed.
8. **Mode 0 layer plan that works:** BG0 = UI text (charbase 2, prio 0),
BG1 = main stage (wide pans = 2 consecutive screenblocks), BG2 = far
parallax, BG3 = sky — or drop the sky layer and repaint the backdrop color
per scanline from the HBlank ISR (160 free shades of gradient).
One palbank per BG layer (15 colors + shared transparent 0), OBJ sheets
frame-major with 1D mapping, UI OBJ tiles parked high (e.g. tile 1000).

## GBA FX vocabulary already proven in `cine/runtime/`

Steal, don't reinvent: IWRAM HBlank ISR (per-scanline backdrop gradient,
letterbox band blackout, sine-wave HOFS), BLDCNT state machine (black/white
BLDY fades — white-out must *persist across scene loads*; alpha ghosts via
2nd-target config kept armed), WIN0 letterbox, mosaic, screen shake (LCG),
one OBJ affine matrix (zoom q8 + angle 0-255, double-size flag, matrix written
into oam_shadow fill words), 16-slot tween engine (24.8 fixed point, smoothstep
`f*f*(768-2f)>>16`), PSG blips. VBlank ISR does OAM DMA + scroll latch.

## The cue/script VM pattern

Author interactive logic as a generator: `yield op(...)` per beat. The compiler
walks the TS AST (if/while/break/choice-comparisons, locals interned per cue) and
emits bytecode; the C VM executes bursts (≈64 ops) between **blocking waiting
states** (WAITING_A / DIALOG / CHOICE / CONTROL / MASH / DONE...) serviced once
per frame. vars/flags are the E2E observability surface — expose them in the
debug block.

## Headless verification (do this, not "it compiles")

- Reserve a **debug block in EWRAM 0x02000000**: magic, booted, scene id,
waiting state, VM ip, camera, vars, sprite-0 position. Update every frame.
- Drive the ROM with `aot/test/harness/mgba_runner` scenario JSON:
`advance` N frames, `press` buttons, `read` any bus address (EWRAM, OAM,
PALRAM, VRAM — invaluable for "sprite invisible" forensics), `screenshot`.
- E2E = full playthrough with assertions on the debug block (scene transitions,
choice results, var math, text pointer) — cine's 27-assert suite is the model.
- **Visually review every scene**: screenshot → PPM → PNG → look at it. Bus
reads prove state; only eyes catch floating characters, palette mud, and
garbled captions.

## Content discipline

- Real-person tribute games: research first (background agent → source-cited
dossier, verified vs unverified separated), avoid every unverified fact,
all dialogue original writing, disclaimer on the title screen.
- Art via the pixel-art pipeline skill (PixelLab, committed PNG cache).
Franchise-neutral prompts; exact logos get baked from vector geometry instead.
- Repo rules: draft PR when validated, Conventional Commits title, never
print or commit API keys (`.env` is git-ignored).
95 changes: 95 additions & 0 deletions .claude/skills/pixel-art-pipeline/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
name: pixel-art-pipeline
description: Generate committed pixel-art assets for GBA games via the PixelLab API (with codex/gpt-image as a secondary tool), including prompt recipes, caching/manifest discipline, and when to bake art from vector geometry instead. Use when creating or regenerating backgrounds, sprites, portraits, or props for aot/cine/saga-style packages.
---

# Pixel-art asset pipeline

## PixelLab (primary tool for GBA-native art)

- API `https://api.pixellab.ai/v1`, Bearer key `PIXELLAB_API_KEY` from the
**repo-root `.env`** (git-ignored — never print or commit it). Typed client:
`cine/pixellab/client.ts` (`pixflux()` + retry ×4 backoff, no retry on 422/401).
- `generate-image-pixflux`: text → pixel art at the **native target size**.
Limits: max 400×400, **min 32×32** ("Canvas must be size 32x32 area or
larger"). Full GBA backgrounds (240×160) and wide pans (384×160) come out
directly — no downscaling ever needed.
- `/balance` shows `{"usd": 0.0}` on subscription accounts — generations still
work; it bills per generation, so **cache everything**.

### Caching discipline (why builds never re-bill)

One generator script per game (`pixellab/generate.ts`) holding a SPECS table:
`{ name, width, height, description, negative, seed, ... }` with **fixed seeds**.
Generated PNGs are **committed** under `<game>/art/` next to a `manifest.json`
recording the exact request; the script skips files that exist. `--only <name>`
+ `--force` to redo one asset; tweak the seed when re-rolling composition.

### Prompt recipes that actually worked (cine, 25 assets)

- Backgrounds: subject + explicit props list + light ("warm evening light",
"dawn"), "wide shot, detailed pixel art game background",
`shading: "detailed shading"`, `detail: "highly detailed"`,
negative `"people, text, logo, watermark"`. Quality is shockingly good at
240×160; interiors and skylines both.
- Character sprites: "**tiny full body** pixel art sprite of ... , standing,
**head to toe visible**", `noBackground: true`, `outline: "single color black
outline"`, negative `"portrait, bust, cropped"`. Without the full-body
incantation you get bust portraits at any canvas size.
- Top-down (Pokémon-style) sprites: add `view: "low top-down"` +
`direction: "south" | "north" | "east"`.
- Props: describe *material and silhouette*, and put what the model drifts
toward in the negative (a flag kept coming out as a signpost until
negative `"arrow, sign"` + "rectangular dark navy fabric flag waving").
- 15-color palbank target: prompts with "limited palette" quantize cleaner
through the median-cut pipeline, but PixelLab output generally survives
15-color quantization well without it.

### What PixelLab cannot do → bake from geometry

Exact marks/logos (e.g. the Vue logo) never come out right from any generator.
Rasterize from the official SVG path vertices instead: ray-casting
point-in-polygon, 4×4 supersampling, ≥50% coverage threshold, snap to the
official flat colors, native sprite size, no AA. Reference implementation:
`cine/film/bake-logo.ts`. Same trick applies to flags, pixel headlines, and any
trademarked-shape stand-in you have vector data for.

## codex imagegen (gpt-image) as a secondary tool

The local codex skill (`~/.codex/skills/.system/imagegen`) drives OpenAI
gpt-image; invoke headlessly with
`codex exec --sandbox workspace-write --skip-git-repo-check "<prompt>"`
(ChatGPT-login auth; no OPENAI_API_KEY needed for the built-in tool path).
Measured head-to-head on the same GBA subjects (garage background, full-body
sprite, 1970s computer prop, 4-dir walk sheet):

- **Backgrounds: both excellent.** gpt-image composes richer scenes/lighting
and even self-downscales to the exact 240×160 target, but its soft gradients
can mud a 15-color palbank; PixelLab stays pixel-grid-true and quantizes
cleaner. Default PixelLab; reach for gpt-image when pixflux won't follow a
specific composition/mood.
- **Sprites & props ≤64px: PixelLab wins clearly.** Native canvas size, true
alpha (`noBackground`), `view`/`direction`/`outline` control. gpt-image edges
smear at sprite scale and its chroma-key background needs a removal pass
(`remove_chroma_key.py`) with fringe risk on 1px outlines.
- **Multi-frame sheets: gpt-image only — but curate.** It will produce a
plausible 3×4 walk-cycle grid in one shot; cell consistency is good but
direction/phase semantics are unreliable (rows come out wrong or as poses,
not walk phases). Treat as raw material. For walk cycles prefer per-direction
pixflux (`direction: south/north/east`, same seed) + mirrored-step animation.
- **Exact marks/logos: neither.** Bake from vector geometry (above).
- **Ops:** PixelLab = seeded, reproducible, manifest-cached, ~5-15s/image.
codex = no seed control, ~90s/image through an agent loop, ChatGPT-login
auth, output lands wherever the agent decides (pin exact filenames in the
prompt). Full head-to-head with images: `saga/docs/imagegen-eval/`.

## Shared rules

- Franchise-neutral prompts always — no trademarked logos, mascots, or trade
dress in any prompt; real marks only by explicit user request and then baked
locally from vector geometry.
- Commit the PNGs, review each one visually (Read the file) before wiring it
into a scene; regenerate with a stronger prompt + new seed rather than
accepting a wrong pose/crop.
- Characters must be generated (or cropped) so their **feet reach the sprite
bottom edge** — the runtime places sprites by feet line.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ host-web/pocketjs.wasm
test/goldens/*.actual.png
# golden-mismatch dumps written by test/e2e-ppsspp.ts
test/goldens-psp/*.actual.png
.env
15 changes: 14 additions & 1 deletion aot/.gitignore
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
# Build artifacts
dist/
# The cartridge blob is generated by the compiler and linked into the ROM.
runtime/gen_cart.c
runtime/gba/gen_cart.c
# Test harness binary (built from mgba_runner.c)
test/harness/mgba_runner
# Transient transpiled game modules from the static evaluator
demo/*.__pjgb.*.mjs
# GB backend build artifacts (generated per game build)
runtime/gb/gen/
runtime/gb/gen_data.h
runtime/gb/*.o
runtime/gb/*.lst
runtime/gb/*.asm
runtime/gb/*.sym
runtime/gb/*.rel
runtime/gb/*.ihx
runtime/gb/*.map
runtime/gb/*.noi
# Transient transpiled game modules (any demo dir)
demo-shendiao/*.__pjgb.*.mjs
Loading