diff --git a/.claude/skills/gba-dsl-game/SKILL.md b/.claude/skills/gba-dsl-game/SKILL.md new file mode 100644 index 0000000..433973f --- /dev/null +++ b/.claude/skills/gba-dsl-game/SKILL.md @@ -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) + +``` +/spec/.ts THE single source of truth: opcode table, tween/FX targets, + waiting states, VRAM budget constants, debug-block offsets. +/spec/gen-c.ts emits runtime/_gen.h from the spec. TS and C never + drift because both read the same table. +/dsl/index.ts defineX() factories + residual op stubs (return plain + tagged objects; no logic). +/compiler/ evaluate → residualize → assets → emit gen_data.c → rom +/runtime/ fixed C files: crt0.s, gba.h, gba.ld, irq.c, cue/script VM, + fx compositor, caption/dialog UI, obj.c, sfx +/test/e2e.ts headless mGBA playthrough with bus-level assertions +``` + +Build = `bun compiler/cli.ts build .ts` → evaluate the module (Bun temp-file +trick: write `entry.__...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). diff --git a/.claude/skills/pixel-art-pipeline/SKILL.md b/.claude/skills/pixel-art-pipeline/SKILL.md new file mode 100644 index 0000000..7683dd1 --- /dev/null +++ b/.claude/skills/pixel-art-pipeline/SKILL.md @@ -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 `/art/` next to a `manifest.json` +recording the exact request; the script skips files that exist. `--only ` ++ `--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 ""` +(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. diff --git a/.gitignore b/.gitignore index bd61beb..0964eed 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/aot/.gitignore b/aot/.gitignore index 440167b..0abed43 100644 --- a/aot/.gitignore +++ b/aot/.gitignore @@ -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 diff --git a/aot/README.md b/aot/README.md index 5c4ad49..bd98660 100644 --- a/aot/README.md +++ b/aot/README.md @@ -1,133 +1,78 @@ # @pocketjs/aot -**Write cartridge RPGs in TypeScript and JSX; ship GBA-native tiles, sprites, palettes, and bytecode.** +**Write cartridge RPGs in TypeScript and JSX; ship console-native tiles, sprites, palettes, and bytecode for GBA, Game Boy, and NES.** -`@pocketjs/aot` is a first-class PocketJS architecture that coexists with `@pocketjs/framework`. Where the framework runs a live Solid/Vue reactive UI on PSP-class hardware, `@pocketjs/aot` goes *below the runtime line*: it **partially evaluates** a TypeScript/JSX game program at build time and emits a small fixed GBA runtime plus binary game data. No JS engine, no Solid, no VDOM, no CSS runtime ships on the cartridge. +`@pocketjs/aot` is a first-class PocketJS architecture that coexists with `@pocketjs/framework`. Where the framework runs a live Solid/Vue reactive UI on PSP-class hardware, `@pocketjs/aot` goes *below the runtime line*: it **partially evaluates** a TypeScript/JSX game program at build time and emits a small fixed native runtime plus binary game data — per console. No JS engine, no Solid, no VDOM ships on any cartridge. -> TypeScript and JSX are the authoring language. Partial evaluation is the compiler strategy. GBA-native tile/sprite data and bytecode are the runtime artifact. +> TypeScript and JSX are the authoring language. Partial evaluation is the compiler strategy. Console-native tile/sprite data and bytecode are the runtime artifact. **One game source compiles to `.gba`, `.gb`, and `.nes`.** -This is not a TypeScript-to-GBA compiler. It is a domain-aware partial evaluator for a constrained RPG DSL (design: `pocketjs_gba_partial_evaluation_design.md`). +This is not a TypeScript-to-console compiler. It is a domain-aware partial evaluator for a constrained RPG DSL (design: `pocketjs_gba_partial_evaluation_design.md`). ## Status -A complete **vertical slice** runs end-to-end: a retro monster-RPG-style overworld authored in TSX compiles to a real `.gba` ROM and passes a headless mGBA E2E suite (19 assertions on real emulated hardware) covering boot, grid movement, collision, NPC dialogue, a choice menu, a battle→flag→item reward, and warping between maps. +Three targets run the same games end to end, verified by one cross-target E2E contract: -| ![town](docs/town.png) | ![dialogue](docs/dialogue.png) | ![choice](docs/choice.png) | -|---|---|---| +- **GBA** — arm-none-eabi-gcc runtime, PJGB chunk cartridge, headless libmgba harness. Header logo/checksum pass for real hardware. +- **Game Boy (DMG)** — GBDK-2020/SDCC runtime, MBC5 autobanked data, window textbox with STAT-safe typewriter glyph streaming, headless mGBA GB core. +- **NES** — cc65/UNROM runtime with CHR-RAM, custom crt0 + NMI-owned VRAM update buffer, nametable-overlay textbox, headless jsnes harness. + +The same `demo/game.tsx` (Pokemon-like town) passes the identical scenario suite on all three (`test/e2e-multi.ts`), and `demo-shendiao/` — a 神雕侠侣 fan mini-RPG with a title-screen chapter select, three story segments, CJK dialogue, choices, items, flags, and a scripted turn-based boss battle — passes a full-story suite on all three (`test/e2e-shendiao.ts`). -*Left: the Littleroot-style town (houses, pond, sign, NPCs, player). Middle: NPC dialogue in a palette-shaded, subpixel-covered textbox. Right: the `choose()` menu with cursor. All rendered by the C runtime from compiled PJGB data — no JS on the cartridge.* +| ![gba](docs/town.png) | ![dialogue](docs/dialogue.png) | ![choice](docs/choice.png) | +|---|---|---| ## Architecture ``` Source TS/TSX (@pocketjs/aot DSL) - -> evaluate static JSX/declaration zone is EXECUTED at build time (compiler/evaluate.ts) - -> bake tilesets/sprites/font -> GBA 4bpp tiles + BGR555 pals (compiler/bake.ts) - -> residualize script(function*(){...}) ASTs -> stack-VM bytecode (compiler/script.ts) - -> model JSX scene trees -> concrete maps/actors/warps (compiler/model.ts) - -> lower validate + emit PJGB chunks (compiler/lower.ts) - -> pack chunks -> PJGB cartridge blob (compiler/pack.ts) - -> rom link the fixed C runtime with arm-none-eabi-gcc -> .gba (compiler/rom.ts) - -> mGBA headless libmgba harness drives input + asserts state (test/) + -> evaluate static JSX/declaration zone EXECUTED at build time (compiler/evaluate.ts) + -> collectAssets tilesets/sprites -> target-NEUTRAL pixel data (compiler/assets.ts) + -> residualize script(function*(){...}) ASTs -> stack-VM bytecode (compiler/script.ts) + (text is wrapped + paginated per target here) + -> model JSX scene trees -> concrete maps/actors/warps (compiler/model.ts) + -> target backend (compiler/targets/*) + gba: 4bpp/BGR555 encode -> PJGB chunk blob -> link with arm-none-eabi-gcc + gb: DMG 2bpp encode -> autobanked gen_data C arrays -> GBDK-2020 lcc (MBC5) + nes: planar 2bpp + NES palettes -> compiler-assigned UNROM banks + + generated ld65 config/iNES header -> cc65/ld65 + -> emulator harnesses drive input + assert a fixed RAM debug block + mGBA (gba + gb, test/harness/mgba_runner.c) / jsnes (nes, nes_runner.ts) ``` -The **two zones** (design §8): - -- **Static declaration zone** — `defineGame`/`defineMap`/``/``/``… is *executed* at build time. JSX builds a scene tree, not a UI tree; pure components expand and never reach the GBA. -- **Residual script zone** — `script(function*(){ yield say(...) })` is *never executed*. The compiler reads the generator AST and lowers supported statements to bytecode, folding static values and residualizing runtime values (flags, choices, battle results) into branches. - -## The DSL - -```tsx -/** @jsxImportSource @pocketjs/aot */ -import { ascii, defineGame, defineMap, tile, - Entrance, Npc, PlayerSpawn, Sign, Warp, - script, say, choose, hasFlag, setFlag, battle, giveItem, - lockPlayer, releasePlayer, facePlayer } from "@pocketjs/aot"; -import { hero, town } from "./assets"; - -const RivalTalk = script(function* () { - yield lockPlayer(); - yield facePlayer("rival"); - if (yield hasFlag("beat_rival_1")) { - yield say("The road ahead is tougher than it looks."); - } else { - yield say("You made it! Want to test your first build?"); - switch (yield choose(["Battle", "Maybe later"] as const)) { - case "Battle": - yield battle("rival_1"); - yield setFlag("beat_rival_1"); - yield giveItem("potion", 1); - yield say("Take this Potion. You will need it."); - break; - case "Maybe later": - yield say("No problem. I will be right here."); - break; - } - } - yield releasePlayer(); -}); - -function LittlerootEntities() { - return ( - <> - - - - - - - ); -} - -const Littleroot = defineMap("littleroot") - .tileset(town) - .layer( - ascii` - ######## - #......# - #..HH..# - ######## - `.legend({ - "#": tile("tree"), - ".": tile("grass"), - H: tile("wall"), - }), - ) - .entities() - .done(); - -export default defineGame({ title: "POCKET TOWN", start: "littleroot:spawn", maps: [Littleroot] }); -``` +The **two zones** (design §8) are unchanged: the static declaration zone (`defineGame`/`defineMap`/``…) is executed at build time; the residual script zone (`script(function*(){ yield say(...) })`) is never executed — the compiler lowers the generator AST to bytecode for a small suspendable stack VM that each console runtime implements identically (ops: text/choice/flags/vars/compare/rnd/warp/while-loops…). -Tile layers stay on the builder path so `.tileset(town).layer(...)` can type-check -legend tile names against the selected tileset. JSX is used one layer later for -build-time scene prefabs: pure components expand into static `Npc`/`Sign`/`Warp` -nodes and never ship to the cartridge. +**The cross-target contract is the E2E suite**: every runtime writes the same debug block layout to a fixed RAM address (GBA EWRAM / GB WRAM 0xDE00 / NES $0700), and one scenario script drives the same logical playthrough through each console's emulator. -See `demo/game.tsx` (the town + route), `demo/assets.ts` (DSL declarations), and `demo/imagegen/` (the source sheet plus deterministic GBA 4bpp extractor). +## Text: cjk16 mode -## Binary contract +Dialogue in 神雕旧事 is Chinese, rendered from **GNU Unifont** 16x16 bitmaps. The compiler bakes only the glyphs a game actually uses (~220 for the demo), wraps and paginates every string per target at build time, and each runtime streams glyph tiles on demand into a reserved VRAM/CHR-RAM slot region (a Game Boy has nowhere near enough VRAM for a static CJK font — this is how commercial-era CJK carts did it too). ASCII-only games on GBA keep the legacy 8x8 Inter-rasterized font (`textMode: "ascii8"`). -`spec/pjgb.ts` is the single source of truth for the **PJGB cartridge format**, the **script VM ISA**, and the **debug block**. `spec/gen-c.ts` generates `runtime/pjgb_gen.h` so the C runtime can never drift from the TS compiler (mirrors the repo's `spec/gen-rust.ts` convention). The runtime reads fixed-width tables and offsets — it never parses JSON. +## The 神雕旧事 demo (`demo-shendiao/`) -## The GBA runtime (`runtime/`) - -A small, no-allocation native engine in C: bare-metal `crt0.s` + `gba.ld`, a chunk loader, Mode-0 tiled BG + hardware-sprite OAM with VBlank DMA, grid movement/collision, a suspendable stack-VM (`script_vm.c`), and a subpixel-covered tile textbox/choice menu. It writes live game state into a fixed EWRAM debug block so the emulator harness can assert without symbols. Cross-compiles with `arm-none-eabi-gcc`; boots in mGBA (and is structured for real hardware, pending a Nintendo-logo header pass — see design §26.6). +A fan-made (同人) mini-RPG: the title screen auto-opens a chapter menu (剑冢神雕 / 断肠之约 / 襄阳大战 / 问世间情); each segment is a few minutes of exploration, dialogue, choices, and — in 襄阳大战 — a fully scripted turn-based boss battle written as a `while` loop over HP/气 vars in the DSL. Completing all three unlocks a short epilogue. All dialogue is original text written for the demo. Art is generated with `bun imagegen` (see `skills/pocketjs-gba-imagegen`) and deterministically quantized per target (`demo-shendiao/imagegen/build-assets.ts`). ## Build & test ```bash -# prerequisites: bun, arm-none-eabi-gcc + binutils, mgba (libmgba) -bun aot/spec/gen-c.ts # regenerate runtime/pjgb_gen.h -bash aot/test/harness/build.sh # build the headless mGBA runner (once) +# prerequisites: bun, arm-none-eabi-gcc + binutils, mgba (libmgba), +# GBDK-2020 (~/.pocketjs/toolchains/gbdk or $GBDK_HOME), cc65 +bun aot/spec/gen-c.ts # regenerate per-target pjgb_gen.h +bash aot/test/harness/build.sh # build the headless mGBA runner (once) cd aot -bun run build # refresh imagegen assets + build ROM -bun run test # refresh assets + run the headless mGBA E2E suite +bun run build # pocket-town .gba (also: build:gb, build:nes) +bun run shendiao # 神雕旧事 .gba + .gb + .nes +bun run test # legacy GBA suite (19 assertions) +bun run test:all # cross-target: pocket-town (54) + 神雕 full story (69) ``` -Outputs: `dist/pocket-town.gba`, `.pjgb` (cartridge blob), `.ir.json` (inspectable IR), `.debug.json` (flag/text/map symbol map for the harness). +Outputs land in `aot/dist/`: `pocket-town.{gba,gb,nes}`, `shendiao.{gba,gb,nes}`, plus per-build `.ir.json` / `.debug.json` (symbol maps for the harnesses) and `dist/shots/*.ppm` screenshots from E2E runs. + +## Real hardware + +- **GBA**: the ROM header gets the BIOS-required logo bitmap, title, and complement checksum (`compiler/rom.ts`) — flashcart-ready. +- **GB**: GBDK-2020's `makebin` emits a valid MBC5 header (logo included) — flashcart-ready. +- **NES**: standard iNES (mapper 2/UNROM + CHR-RAM), the best-supported discrete-mapper profile on flashcarts and emulators. ## v1 scope & follow-ups -Deliberately narrow (design §5, §26): tile-based overworld with scripts. v1 uses 8×8 BG tiles (16×16 metatiles are a documented follow-up), one tileset per game, 16×16 sprites, ≤32×32 maps (single screenblock), and stubbed `battle`/`giveItem`. Not yet: TMX/Aseprite import, audio, save media, and the real-hardware header/logo pass. +Still deliberately narrow (design §5, §26): 8x8 BG tiles, one tileset per game, 16x16 sprites (2 walk frames player / 1 NPC on 8-bit targets), ≤32x32 maps (≤32x30 on NES, which does not scroll in v1), stubbed `giveItem`/`battle` ops (the demo's battle is fully scripted in the DSL instead). Not yet: audio, save media, tile animation, NPC movement kinds, TMX/Aseprite import, GBC color pass. diff --git a/aot/a.ihx b/aot/a.ihx new file mode 100644 index 0000000..09283dd --- /dev/null +++ b/aot/a.ihx @@ -0,0 +1,21 @@ +:01002000E9F6 +:05002800220D20FCC9BF +:070030001A22130D20FAC98A +:08004000F5E521A5C0C3800015 +:20008000C5D52AB6280BE53A6E67E7E12318F3E804D1C1E1F041E60220FAF1D921A3C034B5 +:2000A000200223343E01E091C380FFF041E60220FA3EC0C384FFAF01110021A0C0CD00024D +:2000C0003EC0E09267AF6F0EA0C32800F3E0FFAFFBE00FC9F092B7C8E0463E283D20FDC9AE +:0100F000C946 +:02010000185590 +:20010400CEED6666CC0D000B03730083000C000D0008111F8889000EDCCC6EE6DDDDD999DF +:16012400BBBB67636E0EECCCDDDC999FBBB9333E5469746C650079 +:20014400000000000000000001000000FAA1C047FAA0C0F357583100E0D5CD2602CDB6009E +:20016400D17AEAA0C0FE112007AFCB3B17EAA1C0AFE042E043E041E04A3E07E04B11D40005 +:200184002180FF0E0CF7CD80FF119C00CDED013EE4E047E0483E1BE0493EC0E0403E01E0C6 +:2001A400FFAFE00F21A3C02277E0263CE090CD3E02FBCD0000760018FCF040E680C8AFE083 +:2001C400917600F091B728F9C921A5C02A4F2A47B1C87BB920F67AB820F2444D0B0B2A02A8 +:1701E40003572A0203B220F6C921A5C02AB628032318F97A3273C93D +:0D023E00010000213E0211B1C0CD0902C92E +:200200005F78B1C8730B545D1378B1C8CB38CB1930032A1213040C18062A12132A12130D19 +:1E02200020F70520F4C9F040E680C8F044FE9230FAF044FE9138FAF040E67FE040C908 +:00000001FF diff --git a/aot/bun.lock b/aot/bun.lock new file mode 100644 index 0000000..02d0fdd --- /dev/null +++ b/aot/bun.lock @@ -0,0 +1,18 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@pocketjs/aot", + "devDependencies": { + "jsnes": "^2.1.0", + "typescript": "^5", + }, + }, + }, + "packages": { + "jsnes": ["jsnes@2.1.0", "", {}, "sha512-LUa++E5Dfs1A6ZYB5HiSOR4JrUzrZEXGLZ5Imczhu9lkg7f+94SqnLZ/MXwGa7oc02+j/xYyHR0azGQ6LCU68g=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + } +} diff --git a/aot/compiler/assets.ts b/aot/compiler/assets.ts new file mode 100644 index 0000000..d09faf8 --- /dev/null +++ b/aot/compiler/assets.ts @@ -0,0 +1,79 @@ +// aot/compiler/assets.ts — Stage: parse declared tilesets/sprites into +// target-NEUTRAL pixel data (palette-index grids + RGB palettes). Replaces the +// GBA-only half of the old bake.ts; each target backend encodes these grids +// into its native tile format at lowering time. + +import type { Ctx, Rgb } from "./context.ts"; +import type { Registry } from "../dsl/index.ts"; + +export function parseRows(rows: readonly string[], w: number, h: number): number[] { + if (rows.length !== h) throw new Error(`tile grid: expected ${h} rows, got ${rows.length}`); + const px: number[] = []; + for (let y = 0; y < h; y++) { + const r = rows[y]; + if (r.length !== w) throw new Error(`tile grid row ${y}: expected ${w} cols, got ${r.length} ("${r}")`); + for (let x = 0; x < w; x++) px.push(parseInt(r[x], 16) & 0xf); + } + return px; +} + +export function collectAssets(ctx: Ctx, registry: Registry): void { + const game = registry.game!; + // v1: every map shares one tileset. (game.maps, not registry.maps — helper + // modules are cached across compiles in one process.) + const tilesetNames = new Set(game.maps.map((m) => m.tileset)); + if (tilesetNames.size !== 1) { + throw new Error(`v1 supports one tileset per game (found: ${[...tilesetNames].join(", ")})`); + } + const tileset = registry.tilesets.get([...tilesetNames][0]); + if (!tileset) throw new Error(`tileset "${[...tilesetNames][0]}" not defined`); + + // --- BG palette bank 0 (RGB; targets quantize as needed) --- + ctx.bgPaletteRgb = tileset.palette.slice(0, 16).map((c) => [c[0], c[1], c[2]] as Rgb); + + // --- BG tiles: blank + tileset tiles (neutral index grids) --- + ctx.bgTilePx.push(new Array(64).fill(0)); // id 0 blank + ctx.tileSolid.push(false); + for (const [name, decl] of Object.entries(tileset.tiles)) { + ctx.tileNameToId.set(name, ctx.bgTilePx.length); + ctx.bgTilePx.push(parseRows(decl.px, 8, 8)); + ctx.tileSolid.push(!!decl.solid); + } + + // --- sprites: 16x16 frame grids, dir-major (down,up,left,right) --- + let spriteIdx = 0; + let frameBase = 0; + for (const [name, decl] of registry.sprites) { + const [w, h] = decl.size; + if (w !== 16 || h !== 16) throw new Error(`v1 sprites must be 16x16 ("${name}" is ${w}x${h})`); + if (spriteIdx >= 16) throw new Error(`v1 supports at most 16 sprites; "${name}" is #${spriteIdx}`); + const dirs = ["down", "up", "left", "right"] as const; + const frames = decl.facings.down.length; + const grids: number[][] = []; + for (const d of dirs) { + const fr = decl.facings[d]; + if (fr.length !== frames) throw new Error(`sprite "${name}" facing ${d}: frame count mismatch`); + for (const frame of fr) grids.push(parseRows(frame, 16, 16)); + } + ctx.spriteFrames16.push(grids); + ctx.spriteProtos.push({ + name, + id: spriteIdx, + w, + h, + palbank: spriteIdx, // GBA: one OBJ palette bank per sprite + frames, + tileBase: frameBase, // FRAME-block base; targets scale to tile units + palette: decl.palette.slice(0, 16).map((c) => [c[0], c[1], c[2]] as Rgb), + }); + ctx.spriteIds.set(name, spriteIdx); + frameBase += grids.length; + spriteIdx++; + } + + // Pre-seed declared flags/items/battles/vars so ids are stable. + (game.flags ?? []).forEach((f) => ctx.flagId(f)); + (game.vars ?? []).forEach((v) => ctx.varIdOf(v)); + (game.items ?? []).forEach((it) => ctx.items.intern(it)); + (game.battles ?? []).forEach((b) => ctx.battles.intern(b)); +} diff --git a/aot/compiler/bake.ts b/aot/compiler/bake.ts deleted file mode 100644 index f823a16..0000000 --- a/aot/compiler/bake.ts +++ /dev/null @@ -1,119 +0,0 @@ -// aot/compiler/bake.ts — Stage 7a: lower tilesets/sprites/font to GBA 4bpp -// tiles + BGR555 palettes. Fills ctx.bgTiles/objTiles/palettes/tileNameToId/ -// spriteProtos/fontBase/boxTile. - -import { rgb555 } from "../spec/pjgb.ts"; -import { FIRST_CHAR, LAST_CHAR, glyphPixels } from "./font.ts"; -import type { Ctx } from "./context.ts"; -import type { Registry } from "../dsl/index.ts"; - -// GBA 4bpp tile: 32 bytes, 4 bytes/row, low nibble = left pixel. -export function tile4(px: number[]): Uint8Array { - const out = new Uint8Array(32); - for (let row = 0; row < 8; row++) { - for (let c = 0; c < 4; c++) { - const lo = px[row * 8 + c * 2] & 0xf; - const hi = px[row * 8 + c * 2 + 1] & 0xf; - out[row * 4 + c] = lo | (hi << 4); - } - } - return out; -} - -function parseRows(rows: readonly string[], w: number, h: number): number[] { - if (rows.length !== h) throw new Error(`tile grid: expected ${h} rows, got ${rows.length}`); - const px: number[] = []; - for (let y = 0; y < h; y++) { - const r = rows[y]; - if (r.length !== w) throw new Error(`tile grid row ${y}: expected ${w} cols, got ${r.length} ("${r}")`); - for (let x = 0; x < w; x++) px.push(parseInt(r[x], 16) & 0xf); - } - return px; -} - -// Textbox palette (BG bank 15): 1..5 are subpixel coverage ink shades, -// 6 is the opaque textbox background. -const TEXT_INK_START = 1; -const TEXT_INK_LEVELS = 5; -const TEXT_BG = 6; - -export function bake(ctx: Ctx, registry: Registry): void { - const game = registry.game!; - // v1: every map shares one tileset. - const tilesetNames = new Set(registry.maps.map((m) => m.tileset)); - if (tilesetNames.size !== 1) { - throw new Error(`v1 supports one tileset per game (found: ${[...tilesetNames].join(", ")})`); - } - const tileset = registry.tilesets.get([...tilesetNames][0]); - if (!tileset) throw new Error(`tileset "${[...tilesetNames][0]}" not defined`); - - // --- BG tiles: blank, tileset tiles, font glyphs, box fill --- - ctx.bgTiles.push(tile4(new Array(64).fill(0))); // id 0 blank - for (const [name, decl] of Object.entries(tileset.tiles)) { - const px = parseRows(decl.px, 8, 8); - ctx.tileNameToId.set(name, ctx.bgTiles.length); - ctx.bgTiles.push(tile4(px)); - } - - ctx.fontBase = ctx.bgTiles.length; - for (let ch = FIRST_CHAR; ch <= LAST_CHAR; ch++) { - ctx.bgTiles.push(tile4(glyphPixels(ch, TEXT_INK_START, TEXT_BG, TEXT_INK_LEVELS))); - } - ctx.boxTile = ctx.bgTiles.length; - ctx.bgTiles.push(tile4(new Array(64).fill(TEXT_BG))); - - // --- BG palette: bank 0 = tileset; bank 15 = textbox --- - tileset.palette.forEach((rgb, i) => { - if (i < 16) ctx.bgPalette[i] = rgb555(rgb[0], rgb[1], rgb[2]); - }); - ctx.bgPalette[240 + 0] = rgb555(0, 0, 0); - ctx.bgPalette[240 + 1] = rgb555(76, 88, 132); - ctx.bgPalette[240 + 2] = rgb555(116, 128, 168); - ctx.bgPalette[240 + 3] = rgb555(160, 170, 204); - ctx.bgPalette[240 + 4] = rgb555(206, 214, 238); - ctx.bgPalette[240 + 5] = rgb555(248, 248, 248); - ctx.bgPalette[240 + TEXT_BG] = rgb555(24, 32, 72); - - // --- sprites: OBJ tiles + palette bank per sprite --- - let spriteIdx = 0; - for (const [name, decl] of registry.sprites) { - const [w, h] = decl.size; - if (w !== 16 || h !== 16) throw new Error(`v1 sprites must be 16x16 ("${name}" is ${w}x${h})`); - // v1 gives each sprite its own OBJ palette bank; hardware has only 16. - if (spriteIdx >= 16) throw new Error(`v1 supports at most 16 sprites (one OBJ palette bank each); "${name}" is #${spriteIdx}`); - const palbank = spriteIdx; // one OBJ palette bank per sprite - decl.palette.forEach((rgb, i) => { - if (i < 16) ctx.objPalette[palbank * 16 + i] = rgb555(rgb[0], rgb[1], rgb[2]); - }); - const tileBase = ctx.objTiles.length; - const dirs = ["down", "up", "left", "right"] as const; - const frames = decl.facings.down.length; - for (const d of dirs) { - const fr = decl.facings[d]; - if (fr.length !== frames) throw new Error(`sprite "${name}" facing ${d}: frame count mismatch`); - for (const frame of fr) { - const grid = parseRows(frame, 16, 16); - // split 16x16 into 4 tiles: TL, TR, BL, BR (1D OBJ mapping order) - for (const [ox, oy] of [ - [0, 0], - [8, 0], - [0, 8], - [8, 8], - ]) { - const t: number[] = []; - for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) t.push(grid[(oy + y) * 16 + (ox + x)]); - ctx.objTiles.push(tile4(t)); - } - } - } - ctx.spriteProtos.push({ name, id: spriteIdx, w, h, palbank, frames, tileBase }); - ctx.spriteIds.set(name, spriteIdx); - spriteIdx++; - } - - // Pre-seed declared flags/items/battles/vars so ids are stable. - (game.flags ?? []).forEach((f) => ctx.flagId(f)); - (game.vars ?? []).forEach((v) => ctx.varIdOf(v)); - (game.items ?? []).forEach((it) => ctx.items.intern(it)); - (game.battles ?? []).forEach((b) => ctx.battles.intern(b)); -} diff --git a/aot/compiler/cjk.ts b/aot/compiler/cjk.ts new file mode 100644 index 0000000..fe4d3b4 --- /dev/null +++ b/aot/compiler/cjk.ts @@ -0,0 +1,89 @@ +// aot/compiler/cjk.ts — GNU Unifont loader for cjk16 text mode. +// +// Unifont ships every BMP glyph as a 16px-tall 1-bit bitmap in .hex format: +// one line per codepoint, "XXXX:", where halfwidth glyphs are 8px wide +// (32 hex digits) and fullwidth glyphs 16px wide (64 hex digits). That makes +// it the single cross-target glyph source: the compiler bakes only the glyphs +// a game actually uses, in each target's native tile encoding. +// +// Unifont is dual-licensed (GNU GPLv2+ with the font embedding exception / +// SIL OFL 1.1); embedding rendered glyphs in a ROM is explicitly permitted. + +import { gunzipSync } from "bun"; +import { readFileSync } from "node:fs"; + +const HEX_PATH = new URL("../../assets/fonts/unifont-16.0.04.hex.gz", import.meta.url).pathname; + +export interface UnifontGlyph { + /** 8 or 16 pixels wide; always 16 tall. */ + width: 8 | 16; + /** 16 rows; each row is a bitmask, MSB = leftmost pixel. */ + rows: Uint16Array; // length 16 +} + +let table: Map | null = null; + +function load(): Map { + if (table) return table; + const text = new TextDecoder().decode(gunzipSync(readFileSync(HEX_PATH))); + table = new Map(); + for (const line of text.split("\n")) { + const colon = line.indexOf(":"); + if (colon <= 0) continue; + const cp = parseInt(line.slice(0, colon), 16); + const hex = line.slice(colon + 1).trim(); + if (hex.length !== 32 && hex.length !== 64) continue; + const width = hex.length === 32 ? 8 : 16; + const rows = new Uint16Array(16); + const digitsPerRow = width / 4; + for (let r = 0; r < 16; r++) { + rows[r] = parseInt(hex.slice(r * digitsPerRow, (r + 1) * digitsPerRow), 16); + } + table.set(cp, { width: width as 8 | 16, rows }); + } + return table; +} + +/** Glyph for a codepoint, or null if Unifont has none. */ +export function unifontGlyph(cp: number): UnifontGlyph | null { + return load().get(cp) ?? null; +} + +/** True if the char renders fullwidth (2 halfcells). ASCII is halfwidth. */ +export function isFullwidth(ch: string): boolean { + const cp = ch.codePointAt(0)!; + if (cp <= 0x7e) return false; + const g = unifontGlyph(cp); + if (!g) return true; // unknown chars reserve a full cell (rendered blank) + return g.width === 16; +} + +/** + * Rasterize one halfcell (8x16 column) of a glyph into two stacked 8x8 + * palette-index tiles: [top 64 px, bottom 64 px], row-major. + * + * `half` selects the left (0) or right (1) 8px column of a 16px glyph. + * `ink`/`bg` are the palette indices to write. + */ +export function halfcellPixels( + glyph: UnifontGlyph | null, + half: 0 | 1, + ink: number, + bg: number, +): [number[], number[]] { + const top = new Array(64).fill(bg); + const bottom = new Array(64).fill(bg); + if (glyph) { + const shiftBase = glyph.width - 8 * (half + 1); // bits below the column + for (let y = 0; y < 16; y++) { + const row = glyph.rows[y]; + const dst = y < 8 ? top : bottom; + const dy = y & 7; + for (let x = 0; x < 8; x++) { + const bit = (row >> (shiftBase + 7 - x)) & 1; + if (bit) dst[dy * 8 + x] = ink; + } + } + } + return [top, bottom]; +} diff --git a/aot/compiler/cli.ts b/aot/compiler/cli.ts index 4740f01..fc0cf59 100644 --- a/aot/compiler/cli.ts +++ b/aot/compiler/cli.ts @@ -1,47 +1,53 @@ #!/usr/bin/env bun -// aot/compiler/cli.ts — `pocket-aot build src/game.tsx --out dist/game.gba` +// aot/compiler/cli.ts — `pocket-aot build src/game.tsx --target gb --out dist/game.gb` // (design §21). Also emits ir.json / debug.json for the emulator test harness. import { resolve } from "node:path"; import { compile, debugInfo, irJson } from "./index.ts"; -import { buildRom } from "./rom.ts"; +import type { TargetName } from "../spec/pjgb.ts"; function usage(): never { - console.error("usage: pocket-aot build [--out ] [--no-rom]"); + console.error("usage: pocket-aot build [--target gba|gb|nes] [--out ] [--no-rom]"); process.exit(2); } const [cmd, entryArg, ...rest] = process.argv.slice(2); if (cmd !== "build" || !entryArg) usage(); -let out = "aot/dist/pocket-town.gba"; +let out = ""; +let target: TargetName = "gba"; let doRom = true; for (let i = 0; i < rest.length; i++) { if (rest[i] === "--out") out = rest[++i]; - else if (rest[i] === "--no-rom") doRom = false; + else if (rest[i] === "--target") { + const t = rest[++i]; + if (t !== "gba" && t !== "gb" && t !== "nes") usage(); + target = t; + } else if (rest[i] === "--no-rom") doRom = false; else usage(); } +const EXT: Record = { gba: ".gba", gb: ".gb", nes: ".nes" }; +if (!out) out = `aot/dist/game${EXT[target]}`; const entry = resolve(entryArg); const outAbs = resolve(out); -const base = outAbs.replace(/\.gba$/, ""); +const base = outAbs.replace(/\.(gba|gb|nes)$/, ""); const t0 = performance.now(); -const built = await compile(entry); +const built = await compile(entry, target); const di = debugInfo(built) as Record; await Bun.write(base + ".ir.json", JSON.stringify(irJson(built), null, 2)); await Bun.write(base + ".debug.json", JSON.stringify(di, null, 2)); -await Bun.write(base + ".pjgb", built.blob); -console.log(`PocketJS-AOT build: ${built.game.title}`); +console.log(`PocketJS-AOT build: ${built.game.title} [${target}, ${built.mode}]`); console.log(` maps: ${built.model.maps.length}`); console.log(` scripts: ${built.ctx.scripts.length} texts: ${built.ctx.texts.size} flags: ${built.ctx.flags.size}`); -console.log(` BG tiles: ${built.ctx.bgTiles.length} OBJ tiles: ${built.ctx.objTiles.length} sprites: ${built.ctx.spriteProtos.length}`); -console.log(` cartridge: ${built.blob.length} bytes`); +console.log(` BG tiles: ${built.ctx.bgTilePx.length} sprites: ${built.ctx.spriteProtos.length} CJK glyphs: ${built.ctx.fullGlyphs.size}`); if (doRom) { - const r = await buildRom(built.blob, outAbs); - console.log(` ROM: ${r.gba} (${r.size} bytes)`); + const { buildTarget } = await import("./targets/index.ts"); + const r = await buildTarget(built, outAbs); + console.log(` ROM: ${r.rom} (${r.size} bytes)`); } console.log(` done in ${(performance.now() - t0).toFixed(0)}ms`); diff --git a/aot/compiler/context.ts b/aot/compiler/context.ts index 62d8534..cd79506 100644 --- a/aot/compiler/context.ts +++ b/aot/compiler/context.ts @@ -1,5 +1,11 @@ // aot/compiler/context.ts — shared compile state (interned banks + id maps) -// threaded through bake -> model -> script -> ir. +// threaded through assets -> script -> model -> target lowering. +// +// Pixel data is stored target-NEUTRALLY here (palette-index grids + RGB +// palettes); each target backend encodes it into its native tile format +// (GBA 4bpp / GB interleaved 2bpp / NES planar 2bpp) at lowering time. + +import { TARGETS, type TargetName, type TargetSpec } from "../spec/pjgb.ts"; class NameInterner { private m = new globalThis.Map(); @@ -24,6 +30,11 @@ class NameInterner { } } +export type Rgb = readonly [number, number, number]; + +/** A target-neutral 8x8 tile: 64 palette indices (0-15), row-major. */ +export type TilePx = number[]; + export interface SpriteProto { name: string; id: number; @@ -31,7 +42,8 @@ export interface SpriteProto { h: number; palbank: number; frames: number; - tileBase: number; // OBJ tile index of first tile + tileBase: number; // frame-block index (4 8x8 tiles per 16x16 frame) + palette: Rgb[]; // sprite's own 16-color palette (index 0 = transparent) } export interface ScriptOut { @@ -40,29 +52,52 @@ export interface ScriptOut { bytecode: number[]; } +/** A WARP operand awaiting map-index resolution (patched after buildModel). */ +export interface WarpFixup { + scriptId: number; + /** Offset of the OP_WARP operand block within the script's bytecode. */ + at: number; + dest: string; // "map:entrance" +} + export class Ctx { + readonly target: TargetSpec; + + constructor(target: TargetName = "gba") { + this.target = TARGETS[target]; + } + texts = new NameInterner(); flags = new NameInterner(); vars = new NameInterner(); items = new NameInterner(); battles = new NameInterner(); - // filled by bake - bgPalette = new Uint16Array(256); - objPalette = new Uint16Array(256); - bgTiles: Uint8Array[] = []; - objTiles: Uint8Array[] = []; + // filled by assets.ts (target-neutral) + bgPaletteRgb: Rgb[] = []; // 16 entries, bank 0 (map tileset) + bgTilePx: TilePx[] = []; // blank + tileset tiles (indices into bgPaletteRgb) tileNameToId = new globalThis.Map(); + tileSolid: boolean[] = []; // parallel to bgTilePx + spriteFramePx: TilePx[][] = []; // per sprite: 16x16 frames as 4-tile blocks? no — raw 16x16 grids + spriteFrames16: number[][][] = []; // [sprite][frame(dir-major)] -> 256 px indices spriteProtos: SpriteProto[] = []; spriteIds = new globalThis.Map(); + + // cjk16 glyph interning (dense fullwidth glyph ids, in first-use order) + fullGlyphs = new NameInterner(); + fullGlyphId = (ch: string): number => this.fullGlyphs.intern(ch); + + // legacy ascii8 (GBA) font/box tile ids, filled by the GBA backend fontBase = 0; boxTile = 0; + glyphSlotBase = 0; // filled by model mapIndex = new globalThis.Map(); // filled by script (+ synthetic sign scripts) scripts: ScriptOut[] = []; + warpFixups: WarpFixup[] = []; internText(s: string): number { return this.texts.intern(s); diff --git a/aot/compiler/evaluate.ts b/aot/compiler/evaluate.ts index 7598ec8..0776f97 100644 --- a/aot/compiler/evaluate.ts +++ b/aot/compiler/evaluate.ts @@ -12,6 +12,8 @@ import { pathToFileURL } from "node:url"; const DSL_DIR = new URL("../dsl", import.meta.url).pathname; // for jsxImportSource const DSL_INDEX = new URL("../dsl/index.ts", import.meta.url).pathname; +let evalCounter = 0; + export interface ScriptSite { id: number; body: ts.FunctionExpression | ts.ArrowFunction; @@ -84,13 +86,16 @@ export async function evaluateGame(entryPath: string): Promise { // Execute: write to a sibling temp file so relative/abs imports resolve, then // import it. Import the DSL via the SAME absolute path to share REGISTRY. + // The file name carries a per-process counter: Bun caches modules by path + // (query strings do not bust the cache), and one process may compile the + // same entry for several targets. const dsl = (await import(DSL_INDEX)) as typeof import("../dsl/index.ts"); dsl.__resetRegistry(); - const tmp = entryPath + `.__pjgb.${process.pid}.mjs`; + const tmp = entryPath + `.__pjgb.${process.pid}.${evalCounter++}.mjs`; await Bun.write(tmp, js); try { - await import(pathToFileURL(tmp).href + `?t=${Date.now()}`); + await import(pathToFileURL(tmp).href); } finally { await Bun.file(tmp) .exists() diff --git a/aot/compiler/index.ts b/aot/compiler/index.ts index a6d9b61..c1ec0a8 100644 --- a/aot/compiler/index.ts +++ b/aot/compiler/index.ts @@ -1,70 +1,98 @@ -// aot/compiler/index.ts — the @pocketjs/aot compile pipeline (design §11). +// aot/compiler/index.ts — the @pocketjs/aot compile pipeline (design §11), +// now target-parameterized (gba / gb / nes). // Source TS/TSX // -> evaluate (static JSX zone) -> Registry + script ASTs -// -> bake (assets) -> tiles/palettes/sprites/font -// -> script residualizer -> bytecode +// -> collectAssets -> target-neutral tiles/sprites/palettes +// -> script residualizer -> bytecode (text pre-wrapped per target) // -> model -> concrete maps/actors/warps -// -> lower (+ validate) -> PJGB chunks -// -> pack -> cartridge blob +// -> warp fixups -> patch OP_WARP operands +// -> target backend -> ROM (see targets/*) import { evaluateGame } from "./evaluate.ts"; import { Ctx } from "./context.ts"; -import { bake } from "./bake.ts"; -import { compileScript } from "./script.ts"; +import { collectAssets } from "./assets.ts"; +import { compileScript, type TextMode } from "./script.ts"; import { buildModel, type GameModel } from "./model.ts"; -import { lower } from "./lower.ts"; -import { packCart, type Chunk } from "./pack.ts"; -import { DBG, DEBUG_ADDR } from "../spec/pjgb.ts"; +import { DBG, TARGETS, type TargetName } from "../spec/pjgb.ts"; import type { GameDecl } from "../dsl/index.ts"; export interface CompileOutput { + target: TargetName; + mode: TextMode; ctx: Ctx; model: GameModel; - chunks: Chunk[]; - blob: Uint8Array; game: GameDecl; } -export async function compile(entry: string): Promise { +export function effectiveTextMode(game: GameDecl, target: TargetName): TextMode { + if (target !== "gba") return "cjk16"; // 8-bit runtimes only implement cjk16 + return game.textMode ?? "ascii8"; +} + +export async function compile(entry: string, target: TargetName = "gba"): Promise { const ev = await evaluateGame(entry); - const ctx = new Ctx(); - bake(ctx, ev.registry); + const game = ev.registry.game!; + const mode = effectiveTextMode(game, target); + const ctx = new Ctx(target); + collectAssets(ctx, ev.registry); // AST scripts occupy ids 0..N-1 (matching the ScriptRefs the actors carry). for (const site of ev.scripts) { - const bc = compileScript(site, ctx); + const bc = compileScript(site, ctx, mode); const id = ctx.addScript(`script_${site.id}`, bc); if (id !== site.id) throw new Error(`internal: script id ${id} != site ${site.id}`); } - const model = buildModel(ctx, ev.registry); // sign scripts append at ids N+ - const chunks = lower(ctx, model, ev.registry.game!); - const blob = packCart(chunks); - return { ctx, model, chunks, blob, game: ev.registry.game! }; + const model = buildModel(ctx, ev.registry, mode); // sign scripts append at ids N+ + + // Resolve warpTo("map:entrance") operands now that maps/entrances exist. + for (const fx of ctx.warpFixups) { + const [mapName, entName] = fx.dest.split(":"); + const m = model.maps.find((mm) => mm.name === mapName); + if (!m) throw new Error(`warpTo: unknown map "${mapName}"`); + const ent = m.entrances.get(entName ?? "spawn"); + if (!ent) throw new Error(`warpTo: map "${mapName}" has no entrance "${entName}"`); + const bc = ctx.scripts[fx.scriptId].bytecode; + bc[fx.at] = m.index & 0xff; + bc[fx.at + 1] = ent.x & 0xff; + bc[fx.at + 2] = (ent.x >> 8) & 0xff; + bc[fx.at + 3] = ent.y & 0xff; + bc[fx.at + 4] = (ent.y >> 8) & 0xff; + bc[fx.at + 5] = ent.dir & 0xff; + } + + return { target, mode, ctx, model, game }; } -/** Debug map for the emulator test harness: names -> ids/addresses. */ +/** Debug map for the emulator test harnesses: names -> ids/addresses. */ export function debugInfo(out: CompileOutput): unknown { const { ctx, model } = out; + const debugAddr = TARGETS[out.target].debugAddr; const flags: Record = {}; ctx.flags.list().forEach((name, id) => { - flags[name] = { id, byteAddr: DEBUG_ADDR + DBG.FLAGS + (id >> 3), bit: id & 7 }; + flags[name] = { id, byteAddr: debugAddr + DBG.FLAGS + (id >> 3), bit: id & 7 }; + }); + const vars: Record = {}; + ctx.vars.list().forEach((name, id) => { + vars[name] = { id, addr: debugAddr + DBG.VARS + id * 2 }; }); const maps: Record = {}; ctx.mapIndex.forEach((i, name) => (maps[name] = i)); return { title: out.game.title, + target: out.target, + textMode: out.mode, start: model.start, - debugAddr: DEBUG_ADDR, + debugAddr, fields: DBG, flags, + vars, maps, texts: ctx.texts.list(), scripts: ctx.scripts.map((s) => ({ id: s.id, name: s.name, bytes: s.bytecode.length })), - sprites: ctx.spriteProtos, - bgTiles: ctx.bgTiles.length, - objTiles: ctx.objTiles.length, - blobSize: out.blob.length, + sprites: ctx.spriteProtos.map((s) => ({ name: s.name, id: s.id, frames: s.frames })), + bgTiles: ctx.bgTilePx.length, + fullGlyphs: ctx.fullGlyphs.size, }; } @@ -72,11 +100,14 @@ export function debugInfo(out: CompileOutput): unknown { export function irJson(out: CompileOutput): unknown { return { title: out.game.title, + target: out.target, + textMode: out.mode, start: out.model.start, maps: out.model.maps.map((m) => ({ name: m.name, index: m.index, size: [m.w, m.h], + onEnter: m.onEnter === 0xff ? null : m.onEnter, actors: m.actors.map((a) => ({ name: a.name, at: [a.x, a.y], sprite: a.spriteId, onTalk: a.onTalk })), warps: m.warps.map((w) => ({ at: [w.x, w.y], to: `${w.destMap}:${w.destEntrance}`, dest: [w.destMapIdx, w.destX, w.destY] })), entrances: [...m.entrances.entries()], @@ -84,7 +115,6 @@ export function irJson(out: CompileOutput): unknown { scripts: out.ctx.scripts.map((s) => ({ id: s.id, name: s.name, bytes: s.bytecode.length })), texts: out.ctx.texts.list(), flags: out.ctx.flags.list(), + glyphs: out.ctx.fullGlyphs.list().join(""), }; } - -export { packCart }; diff --git a/aot/compiler/lower.ts b/aot/compiler/lower.ts deleted file mode 100644 index d9bbc50..0000000 --- a/aot/compiler/lower.ts +++ /dev/null @@ -1,136 +0,0 @@ -// aot/compiler/lower.ts — Stage 6+7: validate the Game IR, then lower it to -// PJGB chunks (design §11.6-11.7). The GameModel + Ctx together are the IR. - -import { - BUDGET, - ByteWriter, - CHUNK, - GAME_TITLE_LEN, -} from "../spec/pjgb.ts"; -import type { Ctx } from "./context.ts"; -import type { GameModel } from "./model.ts"; -import type { GameDecl } from "../dsl/index.ts"; -import type { Chunk } from "./pack.ts"; - -export function validate(ctx: Ctx, model: GameModel): void { - const err: string[] = []; - if (model.maps.length > BUDGET.MAX_MAPS) err.push(`too many maps (${model.maps.length} > ${BUDGET.MAX_MAPS})`); - if (ctx.spriteProtos.length > BUDGET.MAX_SPRITES) err.push(`too many sprites`); - if (ctx.flags.size > BUDGET.MAX_FLAGS) err.push(`too many flags (${ctx.flags.size} > ${BUDGET.MAX_FLAGS})`); - if (ctx.vars.size > BUDGET.MAX_VARS) err.push(`too many vars (${ctx.vars.size} > ${BUDGET.MAX_VARS})`); - if (ctx.texts.size > BUDGET.MAX_TEXTS) err.push(`too many texts`); - if (ctx.scripts.length > BUDGET.MAX_SCRIPTS) err.push(`too many scripts`); - if (ctx.bgTiles.length > BUDGET.MAX_BG_TILES) err.push(`BG tiles ${ctx.bgTiles.length} > ${BUDGET.MAX_BG_TILES}`); - if (ctx.objTiles.length > BUDGET.MAX_OBJ_TILES) err.push(`OBJ tiles ${ctx.objTiles.length} > ${BUDGET.MAX_OBJ_TILES}`); - for (const m of model.maps) { - if (m.w > 32 || m.h > 32) err.push(`map "${m.name}" ${m.w}x${m.h} exceeds 32x32 (v1 single-screenblock limit)`); - if (m.actors.length > BUDGET.MAX_ACTORS_PER_MAP) err.push(`map "${m.name}" has ${m.actors.length} actors`); - for (const a of m.actors) { - if (a.onTalk !== 0xffff && a.onTalk >= ctx.scripts.length) err.push(`actor "${a.name}" -> bad script ${a.onTalk}`); - } - for (const wp of m.warps) { - if (wp.destMapIdx === undefined) err.push(`unresolved warp on "${m.name}"`); - } - } - if (err.length) throw new Error("IR validation failed:\n - " + err.join("\n - ")); -} - -function u16buf(a: Uint16Array): Uint8Array { - const w = new ByteWriter(); - for (const v of a) w.u16(v); - return w.toUint8Array(); -} -function catTiles(ts: Uint8Array[]): Uint8Array { - const out = new Uint8Array(ts.length * 32); - ts.forEach((t, i) => out.set(t, i * 32)); - return out; -} - -function gameHeader(ctx: Ctx, model: GameModel, game: GameDecl): Uint8Array { - const w = new ByteWriter(); - w.ascii(game.title, GAME_TITLE_LEN); - w.u8(model.start.map).u8(model.start.dir).u16(model.start.x).u16(model.start.y); - w.u8(model.maps.length).u8(ctx.spriteProtos.length); - w.u16(ctx.flags.size).u16(ctx.texts.size).u16(ctx.scripts.length); - w.u16(ctx.fontBase).u16(ctx.boxTile).u16(0); - return w.toUint8Array(); -} - -function spriteTable(ctx: Ctx): Uint8Array { - const w = new ByteWriter(); - for (const s of ctx.spriteProtos) { - w.u16(s.tileBase).u8(s.w).u8(s.h).u8(s.palbank).u8(s.frames).u16(0); - } - return w.toUint8Array(); -} - -function mapChunk(m: GameModel["maps"][number]): Uint8Array { - const HDR = 28; - const tilesOff = HDR; - const collOff = tilesOff + m.tiles.length * 2; - const actorsOff = (collOff + m.collision.length + 3) & ~3; - const warpsOff = actorsOff + m.actors.length * 12; - const w = new ByteWriter(); - w.u16(m.w).u16(m.h).u16(m.actors.length).u16(m.warps.length); - w.u8(m.palbank).u8(0xff).u16(0); - w.u32(tilesOff).u32(collOff).u32(actorsOff).u32(warpsOff); - for (const t of m.tiles) w.u16(t); - for (const c of m.collision) w.u8(c); - w.align4(); - for (const a of m.actors) { - w.u16(a.x).u16(a.y).u8(a.spriteId).u8(a.facing).u8(a.movement).u8(a.flags).u16(a.onTalk).u16(0); - } - for (const wp of m.warps) { - w.u16(wp.x).u16(wp.y).u8(wp.destMapIdx!).u8(wp.destDir!).u16(wp.destX!).u16(wp.destY!).u16(0); - } - return w.toUint8Array(); -} - -function textBank(ctx: Ctx): Uint8Array { - const strs = ctx.texts.list(); - const enc = strs.map((s) => { - const b = new Uint8Array(s.length + 1); - for (let i = 0; i < s.length; i++) b[i] = s.charCodeAt(i) & 0x7f; - return b; - }); - const headerSize = 4 + strs.length * 4; - let cur = headerSize; - const offsets = enc.map((b) => { - const o = cur; - cur += b.length; - return o; - }); - const w = new ByteWriter(); - w.u16(strs.length).u16(0); - for (const o of offsets) w.u32(o); - for (const b of enc) w.bytes(b); - return w.toUint8Array(); -} - -function scriptChunks(ctx: Ctx): { code: Uint8Array; table: Uint8Array } { - const code = new ByteWriter(); - const table = new ByteWriter(); - for (const s of ctx.scripts) { - table.u32(code.length); - code.bytes(s.bytecode); - } - return { code: code.toUint8Array(), table: table.toUint8Array() }; -} - -export function lower(ctx: Ctx, model: GameModel, game: GameDecl): Chunk[] { - validate(ctx, model); - const { code, table } = scriptChunks(ctx); - const chunks: Chunk[] = [ - { kind: CHUNK.GAME, id: 0, data: gameHeader(ctx, model, game) }, - { kind: CHUNK.PAL_BG, id: 0, data: u16buf(ctx.bgPalette) }, - { kind: CHUNK.PAL_OBJ, id: 0, data: u16buf(ctx.objPalette) }, - { kind: CHUNK.TILES_BG, id: 0, data: catTiles(ctx.bgTiles) }, - { kind: CHUNK.TILES_OBJ, id: 0, data: catTiles(ctx.objTiles) }, - { kind: CHUNK.SPRITE_TABLE, id: 0, data: spriteTable(ctx) }, - { kind: CHUNK.TEXT_BANK, id: 0, data: textBank(ctx) }, - { kind: CHUNK.SCRIPT_CODE, id: 0, data: code }, - { kind: CHUNK.SCRIPT_TABLE, id: 0, data: table }, - ]; - for (const m of model.maps) chunks.push({ kind: CHUNK.MAP, id: m.index, data: mapChunk(m) }); - return chunks; -} diff --git a/aot/compiler/model.ts b/aot/compiler/model.ts index 278f961..6a6f6fc 100644 --- a/aot/compiler/model.ts +++ b/aot/compiler/model.ts @@ -3,10 +3,13 @@ // expand into a solid tile + a synthetic one-line text script (design §11.3). import { DIR_NAMES, MOVE_NAMES, OP, ACTOR_FLAG, DIR } from "../spec/pjgb.ts"; +import { wrapPages } from "./text.ts"; import type { Ctx } from "./context.ts"; +import type { TextMode } from "./script.ts"; import type { PjgbNode, Registry } from "../dsl/index.ts"; const NO_SPRITE = 0xff; +const NO_SCRIPT8 = 0xff; export interface ActorModel { name: string; @@ -37,6 +40,7 @@ export interface MapModel { tiles: number[]; collision: number[]; palbank: number; + onEnter: number; // script id or 0xff actors: ActorModel[]; warps: WarpModel[]; entrances: globalThis.Map; @@ -55,11 +59,13 @@ const at = (n: PjgbNode): [number, number] => { const dirOf = (v: unknown, dflt = DIR.DOWN): number => v == null ? dflt : DIR_NAMES[String(v)] ?? dflt; -export function buildModel(ctx: Ctx, registry: Registry): GameModel { +export function buildModel(ctx: Ctx, registry: Registry, mode: TextMode = "ascii8"): GameModel { const game = registry.game!; - registry.maps.forEach((m, i) => ctx.mapIndex.set(m.name, i)); + // Iterate the game's OWN map decls (direct object refs), not the registry + // accumulator: helper modules are cached across compiles in one process. + game.maps.forEach((m, i) => ctx.mapIndex.set(m.name, i)); - const maps: MapModel[] = registry.maps.map((mapDecl, index) => { + const maps: MapModel[] = game.maps.map((mapDecl, index) => { const tileset = registry.tilesets.get(mapDecl.tileset)!; const children = mapDecl.root.children; const layer = children.find((c) => c.host === "Layer"); @@ -116,9 +122,14 @@ export function buildModel(ctx: Ctx, registry: Registry): GameModel { case "Sign": { const [x, y] = at(c); const text = String(prop(c, "text") ?? ""); - // synthetic script: TEXT ; END - const textId = ctx.internText(text); - const bc = [OP.TEXT, textId & 0xff, (textId >> 8) & 0xff, OP.END]; + // synthetic script: TEXT per page; END + const pages = mode === "cjk16" ? wrapPages(text, ctx.target) : [text]; + const bc: number[] = []; + for (const page of pages) { + const textId = ctx.internText(page); + bc.push(OP.TEXT, textId & 0xff, (textId >> 8) & 0xff); + } + bc.push(OP.END); const sid = ctx.addScript(`sign_${index}_${x}_${y}`, bc); const signTile = ctx.tileNameToId.get("sign"); if (signTile !== undefined) { @@ -137,6 +148,25 @@ export function buildModel(ctx: Ctx, registry: Registry): GameModel { }); break; } + case "Trigger": { + // A sprite-less interactable: solid tile the player can face and + // press A on to run a script (stone doors, cliff edges, steles...). + const [x, y] = at(c); + const ref = prop(c, "onTalk") as { __pjgbScript: number } | undefined; + if (!ref) throw new Error(`map "${mapDecl.name}": needs onTalk={script(...)}`); + collision[y * w + x] = 1; + actors.push({ + name: (prop(c, "id") as string) ?? `trigger_${x}_${y}`, + x, + y, + spriteId: NO_SPRITE, + facing: DIR.DOWN, + movement: 0, + flags: ACTOR_FLAG.SOLID, + onTalk: ref.__pjgbScript, + }); + break; + } case "Warp": { const [x, y] = at(c); const to = String(prop(c, "to")); @@ -149,7 +179,13 @@ export function buildModel(ctx: Ctx, registry: Registry): GameModel { } } - return { name: mapDecl.name, index, w, h, tiles, collision, palbank: 0, actors, warps, entrances }; + const onEnterRef = mapDecl.onEnter as { __pjgbScript: number } | undefined; + const onEnter = onEnterRef ? onEnterRef.__pjgbScript : NO_SCRIPT8; + if (onEnter !== NO_SCRIPT8 && onEnter > 0xfe) { + throw new Error(`map "${mapDecl.name}": onEnter script id ${onEnter} exceeds the u8 field`); + } + + return { name: mapDecl.name, index, w, h, tiles, collision, palbank: 0, onEnter, actors, warps, entrances }; }); // resolve warps against destination entrances diff --git a/aot/compiler/rom.ts b/aot/compiler/rom.ts index 57659fd..b7bfbf6 100644 --- a/aot/compiler/rom.ts +++ b/aot/compiler/rom.ts @@ -1,17 +1,40 @@ // aot/compiler/rom.ts — Stage 8: link the PJGB blob into a real .gba ROM. // Emits gen_cart.c, cross-compiles the fixed C runtime with arm-none-eabi-gcc, -// objcopies to a raw ROM, and patches the GBA header complement checksum. +// objcopies to a raw ROM, and patches the GBA header (logo bitmap + title + +// complement checksum) so the ROM passes the real-hardware BIOS boot check, +// not just emulators. import { $ } from "bun"; import { emitCartC } from "./pack.ts"; const ROOT = new URL("../..", import.meta.url).pathname; // repo root -const RT = ROOT + "aot/runtime"; +const RT = ROOT + "aot/runtime/gba"; const DIST = ROOT + "aot/dist"; -/** GBA header complement checksum over bytes 0xA0..0xBC, written at 0xBD. */ -function patchHeaderChecksum(rom: Uint8Array): void { +// The 156-byte compressed logo bitmap the GBA BIOS verifies at boot — a +// functional interoperability constant, identical in every toolchain's +// header fixer (devkitPro gbafix, etc.). +// prettier-ignore +const GBA_LOGO = [ + 0x24, 0xff, 0xae, 0x51, 0x69, 0x9a, 0xa2, 0x21, 0x3d, 0x84, 0x82, 0x0a, 0x84, 0xe4, 0x09, 0xad, + 0x11, 0x24, 0x8b, 0x98, 0xc0, 0x81, 0x7f, 0x21, 0xa3, 0x52, 0xbe, 0x19, 0x93, 0x09, 0xce, 0x20, + 0x10, 0x46, 0x4a, 0x4a, 0xf8, 0x27, 0x31, 0xec, 0x58, 0xc7, 0xe8, 0x33, 0x82, 0xe3, 0xce, 0xbf, + 0x85, 0xf4, 0xdf, 0x94, 0xce, 0x4b, 0x09, 0xc1, 0x94, 0x56, 0x8a, 0xc0, 0x13, 0x72, 0xa7, 0xfc, + 0x9f, 0x84, 0x4d, 0x73, 0xa3, 0xca, 0x9a, 0x61, 0x58, 0x97, 0xa3, 0x27, 0xfc, 0x03, 0x98, 0x76, + 0x23, 0x1d, 0xc7, 0x61, 0x03, 0x04, 0xae, 0x56, 0xbf, 0x38, 0x84, 0x00, 0x40, 0xa7, 0x0e, 0xfd, + 0xff, 0x52, 0xfe, 0x03, 0x6f, 0x95, 0x30, 0xf1, 0x97, 0xfb, 0xc0, 0x85, 0x60, 0xd6, 0x80, 0x25, + 0xa9, 0x63, 0xbe, 0x03, 0x01, 0x4e, 0x38, 0xe2, 0xf9, 0xa2, 0x34, 0xff, 0xbb, 0x3e, 0x03, 0x44, + 0x78, 0x00, 0x90, 0xcb, 0x88, 0x11, 0x3a, 0x94, 0x65, 0xc0, 0x7c, 0x63, 0x87, 0xf0, 0x3c, 0xaf, + 0xd6, 0x25, 0xe4, 0x8b, 0x38, 0x0a, 0xac, 0x72, 0x21, 0xd4, 0xf8, 0x07, +]; + +/** Real-hardware header pass: logo bitmap, title, fixed byte, complement. */ +function patchHeader(rom: Uint8Array, title: string): void { if (rom.length < 0xc0) throw new Error("ROM too small for a GBA header"); + rom.set(GBA_LOGO, 0x04); + const t = title.replace(/[^\x20-\x7e]/g, "").toUpperCase().slice(0, 12).padEnd(12, "\0"); + for (let i = 0; i < 12; i++) rom[0xa0 + i] = t.charCodeAt(i); + rom[0xb2] = 0x96; // fixed value let sum = 0; for (let a = 0xa0; a <= 0xbc; a++) sum += rom[a]; rom[0xbd] = (-(0x19 + sum)) & 0xff; @@ -23,7 +46,7 @@ export interface BuildRomResult { size: number; } -export async function buildRom(blob: Uint8Array, outPath: string): Promise { +export async function buildRom(blob: Uint8Array, outPath: string, title = "POCKETJS"): Promise { await Bun.write(RT + "/gen_cart.c", emitCartC(blob)); const elf = DIST + "/game.elf"; @@ -47,7 +70,7 @@ export async function buildRom(blob: Uint8Array, outPath: string): Promise this.staticVal(a)), call: e }; } + /** say(): one OP_TEXT per compile-time page (cjk16 wraps; ascii8 is 1:1). */ + private emitSay(text: string): void { + const pages = this.mode === "cjk16" ? wrapPages(text, this.ctx.target) : [text]; + for (const page of pages) { + this.u8(OP.TEXT); + this.u16(this.ctx.internText(page)); + } + } + /** Emit an op call. Returns true if it leaves a value on the stack. */ emitOp(name: string, args: unknown[], node: ts.Node): boolean { switch (name) { case "say": - this.u8(OP.TEXT); - this.u16(this.ctx.internText(String(args[0]))); + this.emitSay(String(args[0])); return false; case "lockPlayer": this.u8(OP.LOCK_PLAYER); @@ -127,6 +155,38 @@ class Emitter { this.u8(OP.PUSH_VAR); this.u16(this.ctx.varIdOf(String(args[0]))); return true; + case "varEq": + case "varGt": + case "varLt": + case "varGe": + case "varLe": { + this.u8(OP.PUSH_VAR); + this.u16(this.ctx.varIdOf(String(args[0]))); + this.u8(OP.PUSH_CONST); + this.i16(Number(args[1])); + const cmp = { varEq: OP.EQ, varGt: OP.GT, varLt: OP.LT, varGe: OP.GE, varLe: OP.LE }[name]; + this.u8(cmp); + return true; + } + case "rnd": { + const n = Number(args[0]); + if (!(n >= 1 && n <= 255)) throw new ScriptError(node, this.sf, `rnd(n) needs 1..255 (got ${n})`); + this.u8(OP.RND); + this.u8(n); + return true; + } + case "warpTo": { + const dest = String(args[0]); + if (!dest.includes(":")) throw new ScriptError(node, this.sf, `warpTo needs "map:entrance" (got "${dest}")`); + this.u8(OP.WARP); + // Operands are patched after buildModel resolves map indices/entrances. + this.ctx.warpFixups.push({ scriptId: -1, at: this.code.length, dest }); + this.u8(0); // map + this.u16(0); // x + this.u16(0); // y + this.u8(0); // dir + return false; + } case "playSfx": this.u8(OP.PLAY_SFX); this.u16(0); @@ -137,10 +197,20 @@ class Emitter { } private emitChoice(options: string[], node: ts.Node): void { - // The textbox renders choice rows TEXT_ROW0(13)..BOX_ROW1(19) = 7 rows, so - // an 8th option would be selectable but never shown. Cap at 7. - if (options.length < 1 || options.length > 7) { - throw new ScriptError(node, this.sf, `choose() needs 1..7 options (got ${options.length})`); + // ascii8: the GBA textbox renders up to 7 rows. cjk16: options are 16px + // lines; every target fits maxChoices of them (spec TARGETS). + const max = this.mode === "cjk16" ? this.ctx.target.maxChoices : 7; + if (options.length < 1 || options.length > max) { + throw new ScriptError(node, this.sf, `choose() needs 1..${max} options (got ${options.length})`); + } + if (this.mode === "cjk16") { + for (const o of options) { + if (o.includes("\n")) throw new ScriptError(node, this.sf, `choice option must be one line ("${o}")`); + const cells = textCells(o); + if (cells > this.ctx.target.choiceCols - 2) { + throw new ScriptError(node, this.sf, `choice option too wide for ${this.ctx.target.name} (${cells} > ${this.ctx.target.choiceCols - 2} cells): "${o}"`); + } + } } this.u8(OP.CHOICE); this.u8(options.length); @@ -176,18 +246,67 @@ class Emitter { return; } if (ts.isIfStatement(s)) return this.compileIf(s); + if (ts.isWhileStatement(s)) return this.compileWhile(s); + if (ts.isBreakStatement(s)) { + // NOTE: inside a choose+switch clause, top-level `break` exits the + // switch (handled by compileChoiceSwitch); this path is for breaks in + // while-loop bodies (e.g. `if (yield varLe(...)) break;`). + const loop = this.loopBreaks[this.loopBreaks.length - 1]; + if (!loop) throw new ScriptError(s, this.sf, "break outside a while loop"); + loop.push(this.emitJump(OP.JUMP)); + return; + } + if (ts.isReturnStatement(s)) { + if (s.expression) throw new ScriptError(s, this.sf, "scripts cannot return a value; use plain `return;`"); + this.u8(OP.END); + return; + } if (ts.isBlock(s)) return this.compileBlock(s.statements); if (s.kind === ts.SyntaxKind.EmptyStatement) return; - throw new ScriptError(s, this.sf, "unsupported statement in a script (v1: yield-ops, if/else, choose+switch)"); + throw new ScriptError(s, this.sf, "unsupported statement in a script (yield-ops, if/else, while, choose+switch)"); } - private compileIf(s: ts.IfStatement): void { - if (!ts.isYieldExpression(s.expression)) { - throw new ScriptError(s.expression, this.sf, "if condition must be `yield ` (e.g. yield hasFlag(...))"); + // while (yield ) { ... } — loops over a runtime value. + private compileWhile(s: ts.WhileStatement): void { + const loopStart = this.code.length; + this.emitCondition(s.expression); + const toEnd = this.emitJump(OP.JUMP_IF_FALSE); + this.loopBreaks.push([]); + this.compileStatement(s.statement); + const back = this.emitJump(OP.JUMP); + this.patchTo(back, loopStart); + this.patch(toEnd); + for (const b of this.loopBreaks.pop()!) this.patch(b); + } + + /** + * Emit a testable condition: `yield ` optionally wrapped in any + * number of `!` negations / parens. Leaves 0/1 on the VM stack. + */ + private emitCondition(expr: ts.Expression): void { + let e = expr; + let negs = 0; + for (;;) { + if (ts.isParenthesizedExpression(e)) { + e = e.expression; + } else if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.ExclamationToken) { + negs++; + e = e.operand; + } else { + break; + } + } + if (!ts.isYieldExpression(e)) { + throw new ScriptError(expr, this.sf, "condition must be `yield ` (e.g. yield hasFlag(...)), optionally negated with !"); } - const { name, args, call } = this.opCall(s.expression.expression!); + const { name, args, call } = this.opCall(e.expression!); if (!VALUE_OPS.has(name)) throw new ScriptError(call, this.sf, `"${name}" does not yield a testable value`); this.emitOp(name, args, call); // leaves value + for (let i = 0; i < negs; i++) this.u8(OP.NOT); + } + + private compileIf(s: ts.IfStatement): void { + this.emitCondition(s.expression); const toElse = this.emitJump(OP.JUMP_IF_FALSE); this.compileStatement(s.thenStatement); if (s.elseStatement) { @@ -221,8 +340,10 @@ class Emitter { defaultIndex = i; return; } - const idx = options.indexOf(String(this.staticVal(clause.expression))); - if (idx < 0) throw new ScriptError(clause, this.sf, `case is not one of the choose() options`); + // Case labels may be option strings or option indices (0-based). + const label = this.staticVal(clause.expression); + const idx = typeof label === "number" ? label : options.indexOf(String(label)); + if (idx < 0 || idx >= options.length) throw new ScriptError(clause, this.sf, `case is not one of the choose() options`); this.u8(OP.DUP); this.u8(OP.PUSH_CONST); this.i16(idx); @@ -274,13 +395,16 @@ class Emitter { } } -export function compileScript(site: ScriptSite, ctx: Ctx): number[] { - const em = new Emitter(ctx, site.file); +export function compileScript(site: ScriptSite, ctx: Ctx, mode: TextMode): number[] { + const em = new Emitter(ctx, site.file, mode); const body = site.body.body; if (!body || !ts.isBlock(body)) { throw new Error(`script #${site.id}: expected a block body`); } + const fixupStart = ctx.warpFixups.length; em.compileBlock(body.statements); em.code.push(OP.END); + // Attribute this script's warpTo fixups to its (about-to-be-assigned) id. + for (let i = fixupStart; i < ctx.warpFixups.length; i++) ctx.warpFixups[i].scriptId = site.id; return em.code; } diff --git a/aot/compiler/targets/gb.ts b/aot/compiler/targets/gb.ts new file mode 100644 index 0000000..24bcc9d --- /dev/null +++ b/aot/compiler/targets/gb.ts @@ -0,0 +1,356 @@ +// aot/compiler/targets/gb.ts — the Game Boy backend. +// +// Unlike the GBA (flat ROM, runtime chunk parsing), the GB build residualizes +// all game data into per-bank C arrays (gen_data*.c) compiled with GBDK-2020. +// Banking uses GBDK autobanking: every data file is `#pragma bank 255` and the +// linker (bankpack) assigns MBC5 ROM banks; the runtime switches banks with +// SWITCH_ROM(BANK(sym)) around each access. +// +// Pixel policy (DMG): the authored 16-color art is mapped to 4 shades by +// luminance. BG shade 0 is lightest (BGP identity 0xE4); OBJ color 0 is +// transparent and visible colors map to 1..3. + +import { mkdir } from "node:fs/promises"; +import { $ } from "bun"; +import { + BG_TILE_BUDGET, + BUDGET, + OBJ_TILE_BUDGET, + TILE_2BPP_BYTES, + TOK_ASCII_MIN, +} from "../../spec/pjgb.ts"; +import { halfcellPixels, unifontGlyph } from "../cjk.ts"; +import { tokenize } from "../text.ts"; +import type { CompileOutput } from "../index.ts"; +import type { Rgb } from "../context.ts"; +import type { TargetBuildResult } from "./index.ts"; + +const ROOT = new URL("../../..", import.meta.url).pathname; // repo root +const RT = ROOT + "aot/runtime/gb"; +const GBDK = process.env.GBDK_HOME ?? `${process.env.HOME}/.pocketjs/toolchains/gbdk`; + +// GB script bytecode is copied to a WRAM buffer before execution. +const GB_SCRIPT_BUF = 2048; + +// --------------------------------------------------------------------------- +// Pixel encoding +// --------------------------------------------------------------------------- +const lum = (c: Rgb): number => 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]; + +/** Map an RGB to a DMG shade 0 (lightest) .. 3 (darkest). */ +export function shadeOf(c: Rgb): number { + const l = lum(c); + if (l >= 192) return 0; + if (l >= 128) return 1; + if (l >= 64) return 2; + return 3; +} + +const BAYER2 = [ + [-9, 3], + [9, -3], +]; +function shadeOfLum(l: number): number { + if (l >= 192) return 0; + if (l >= 128) return 1; + if (l >= 64) return 2; + return 3; +} + +/** + * Per-tile 4-shade mapper for downsampled art: pixels near the tile's mean + * luminance flatten TO the mean (killing row-banding from averaged texture), + * distinct features keep their own level, and a Bayer 2x2 offset turns the + * remaining threshold noise into the classic handheld dither. + */ +export function tileShadeMapper(px: number[], palette: Rgb[]): (v: number, x: number, y: number) => number { + const lums = px.map((v) => lum(palette[v] ?? [0, 0, 0])); + const mean = lums.reduce((a, b) => a + b, 0) / lums.length; + return (v, x, y) => { + const own = lum(palette[v] ?? [0, 0, 0]); + const l = Math.abs(own - mean) < 26 ? mean : own; + return shadeOfLum(l + BAYER2[y & 1][x & 1]); + }; +} + +/** GB 2bpp tile: 16 bytes; per row, byte0 = low bitplane, byte1 = high. */ +export function tile2gb(px: number[], toShade: (v: number, x: number, y: number) => number): Uint8Array { + const out = new Uint8Array(TILE_2BPP_BYTES); + for (let row = 0; row < 8; row++) { + let lo = 0; + let hi = 0; + for (let x = 0; x < 8; x++) { + const s = toShade(px[row * 8 + x], x, row) & 3; + lo |= (s & 1) << (7 - x); + hi |= ((s >> 1) & 1) << (7 - x); + } + out[row * 2] = lo; + out[row * 2 + 1] = hi; + } + return out; +} + +// Textbox glyphs: ink = shade 0 (white via BGP), box bg = shade 3 (black). +const GLYPH_INK = 0; +const GLYPH_BG = 3; + +// --------------------------------------------------------------------------- +// C emission helpers +// --------------------------------------------------------------------------- +function cBytes(name: string, bytes: ArrayLike, opts: { banked?: boolean } = {}): string { + const rows: string[] = []; + for (let i = 0; i < bytes.length; i += 16) { + const slice: string[] = []; + for (let j = i; j < Math.min(i + 16, bytes.length); j++) slice.push(`0x${(bytes[j] & 0xff).toString(16).padStart(2, "0")}`); + rows.push(" " + slice.join(",") + ","); + } + const body = [`const unsigned char ${name}[] = {`, ...rows, "};"]; + if (opts.banked) body.push(`BANKREF(${name})`); + return body.join("\n"); +} + +function cWords(name: string, words: number[], opts: { banked?: boolean } = {}): string { + const rows: string[] = []; + for (let i = 0; i < words.length; i += 12) { + rows.push(" " + words.slice(i, i + 12).map((w) => `0x${(w & 0xffff).toString(16)}`).join(",") + ","); + } + const body = [`const unsigned int ${name}[] = {`, ...rows, "};"]; + if (opts.banked) body.push(`BANKREF(${name})`); + return body.join("\n"); +} + +const bankedFile = (body: string[]): string => + ["// GENERATED by @pocketjs/aot (gb backend) — do not edit.", "#pragma bank 255", '#include ', "", ...body, ""].join("\n"); +const fixedFile = (body: string[]): string => + ["// GENERATED by @pocketjs/aot (gb backend) — do not edit.", '#include ', '#include "gen_data.h"', "", ...body, ""].join("\n"); + +// --------------------------------------------------------------------------- +// Lowering +// --------------------------------------------------------------------------- +interface GbFiles { + files: Record; // filename -> contents (written into runtime/gb) + header: string; // gen_data.h +} + +export function lowerGb(out: CompileOutput): GbFiles { + const { ctx, model, game } = out; + const t = ctx.target; + + // --- encode text bank first so the glyph set is complete --- + const texts = ctx.texts.list().map((s) => tokenize(s, ctx.fullGlyphId)); + const textBlob: number[] = []; + const textOffs: number[] = []; + for (const tk of texts) { + textOffs.push(textBlob.length); + textBlob.push(...tk); + } + + // --- BG tiles: blank + tileset + box; glyph slots are VRAM-only --- + const bgTiles: Uint8Array[] = ctx.bgTilePx.map((px) => tile2gb(px, tileShadeMapper(px, ctx.bgPaletteRgb))); + const boxTile = bgTiles.length; + bgTiles.push(tile2gb(new Array(64).fill(0), () => GLYPH_BG)); + const slotBase = bgTiles.length; + const slotTiles = t.glyphSlots * 2; + + // --- glyphs --- + const glyphHalf: number[] = []; + for (let c = TOK_ASCII_MIN; c <= 0x7e; c++) { + const [top, bottom] = halfcellPixels(unifontGlyph(c), 0, GLYPH_INK, GLYPH_BG); + glyphHalf.push(...tile2gb(top, (v) => v), ...tile2gb(bottom, (v) => v)); + } + const glyphFull: number[] = []; + for (const ch of ctx.fullGlyphs.list()) { + const gl = unifontGlyph(ch.codePointAt(0)!); + for (const half of [0, 1] as const) { + const [top, bottom] = halfcellPixels(gl, half, GLYPH_INK, GLYPH_BG); + glyphFull.push(...tile2gb(top, (v) => v), ...tile2gb(bottom, (v) => v)); + } + } + + // --- OBJ tiles: per frame L-halfcell(top,bottom), R-halfcell(top,bottom) --- + const objTiles: number[] = []; + ctx.spriteProtos.forEach((sp, si) => { + const pal = sp.palette; + const objShade = (v: number): number => (v === 0 ? 0 : Math.max(1, shadeOf(pal[v] ?? [0, 0, 0]))); + for (const grid of ctx.spriteFrames16[si]) { + for (const [ox, oy] of [ + [0, 0], + [0, 8], + [8, 0], + [8, 8], + ]) { + const tpx: number[] = []; + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) tpx.push(grid[(oy + y) * 16 + (ox + x)]); + objTiles.push(...tile2gb(tpx, objShade)); + } + } + }); + const objTileCount = objTiles.length / TILE_2BPP_BYTES; + + // --- validation --- + const err: string[] = []; + if (slotBase + slotTiles > BG_TILE_BUDGET.gb) err.push(`BG tiles ${slotBase}+${slotTiles} slots > ${BG_TILE_BUDGET.gb}`); + if (objTileCount > OBJ_TILE_BUDGET.gb) err.push(`OBJ tiles ${objTileCount} > ${OBJ_TILE_BUDGET.gb}`); + if (ctx.fullGlyphs.size > BUDGET.MAX_FULL_GLYPHS) err.push(`too many CJK glyphs (${ctx.fullGlyphs.size})`); + if (textBlob.length > 0xffff) err.push(`text bank too large (${textBlob.length})`); + for (const s of ctx.scripts) { + if (s.bytecode.length > GB_SCRIPT_BUF) err.push(`script ${s.name} is ${s.bytecode.length}B > ${GB_SCRIPT_BUF}B WRAM buffer`); + } + for (const m of model.maps) { + if (m.w > t.maxMapW || m.h > t.maxMapH) err.push(`map "${m.name}" ${m.w}x${m.h} exceeds ${t.maxMapW}x${t.maxMapH}`); + if (m.actors.length > BUDGET.MAX_ACTORS_PER_MAP) err.push(`map "${m.name}" has ${m.actors.length} actors`); + } + if (err.length) throw new Error("GB lowering failed:\n - " + err.join("\n - ")); + + // --- script blob (u16 offsets, n+1 entries) --- + const scriptBlob: number[] = []; + const scriptOffs: number[] = []; + for (const s of ctx.scripts) { + scriptOffs.push(scriptBlob.length); + scriptBlob.push(...s.bytecode); + } + scriptOffs.push(scriptBlob.length); + + // --- per-map data (records use the spec layouts, packed) --- + const files: Record = {}; + const mapInfos: string[] = []; + model.maps.forEach((m) => { + const actors: number[] = []; + for (const a of m.actors) { + actors.push(a.x & 0xff, a.x >> 8, a.y & 0xff, a.y >> 8, a.spriteId, a.facing, a.movement, a.flags, a.onTalk & 0xff, (a.onTalk >> 8) & 0xff, 0, 0); + } + const warps: number[] = []; + for (const wp of m.warps) { + warps.push(wp.x & 0xff, wp.x >> 8, wp.y & 0xff, wp.y >> 8, wp.destMapIdx!, wp.destDir!, wp.destX! & 0xff, wp.destX! >> 8, wp.destY! & 0xff, wp.destY! >> 8, 0, 0); + } + files[`gen_map_${m.index}.c`] = bankedFile([ + cBytes(`pj_map${m.index}_tiles`, m.tiles, { banked: true }), + "", + cBytes(`pj_map${m.index}_coll`, m.collision, { banked: true }), + "", + cBytes(`pj_map${m.index}_actors`, actors.length ? actors : [0], { banked: true }), + "", + cBytes(`pj_map${m.index}_warps`, warps.length ? warps : [0], { banked: true }), + ]); + mapInfos.push( + ` { ${m.w}, ${m.h}, 0x${m.onEnter.toString(16)}, ${m.actors.length}, ${m.warps.length}, ` + + `BANK(pj_map${m.index}_tiles), pj_map${m.index}_tiles, pj_map${m.index}_coll, ` + + `(const PjActor *)pj_map${m.index}_actors, (const PjWarp *)pj_map${m.index}_warps },`, + ); + }); + + files["gen_tiles.c"] = bankedFile([ + cBytes("pj_bg_tiles", bgTiles.flatMap((b) => [...b]), { banked: true }), + "", + cBytes("pj_obj_tiles", objTiles, { banked: true }), + ]); + files["gen_glyphs_half.c"] = bankedFile([cBytes("pj_glyphs_half", glyphHalf, { banked: true })]); + files["gen_glyphs_full.c"] = bankedFile([cBytes("pj_glyphs_full", glyphFull.length ? glyphFull : [0], { banked: true })]); + files["gen_texts.c"] = bankedFile([ + cWords("pj_text_offs", textOffs, { banked: true }), + "", + cBytes("pj_texts", textBlob.length ? textBlob : [0], { banked: true }), + ]); + files["gen_scripts.c"] = bankedFile([ + cWords("pj_script_offs", scriptOffs, { banked: true }), + "", + cBytes("pj_scripts", scriptBlob.length ? scriptBlob : [0], { banked: true }), + ]); + + // sprite prototype table (fixed bank; hot) + const sprites = ctx.spriteProtos + .map((sp) => ` { ${sp.tileBase * 4}, ${sp.frames} },`) + .join("\n"); + files["gen_fixed.c"] = fixedFile([ + `const PjMapInfo pj_maps[] = {`, + ...mapInfos, + `};`, + "", + `const PjSprite pj_sprites[] = {`, + sprites || " { 0, 1 },", + `};`, + ]); + + // --- gen_data.h --- + const header = [ + "// GENERATED by @pocketjs/aot (gb backend) — do not edit.", + "#ifndef PJ_GEN_DATA_H", + "#define PJ_GEN_DATA_H", + '#include ', + "", + "typedef struct { unsigned int x, y; unsigned char sprite, facing, move, flags; unsigned int on_talk, rsv; } PjActor;", + "typedef struct { unsigned int x, y; unsigned char dest_map, dest_dir; unsigned int dest_x, dest_y, rsv; } PjWarp;", + "typedef struct { unsigned char w, h, on_enter, n_actors, n_warps, bank;", + " const unsigned char *tiles; const unsigned char *coll;", + " const PjActor *actors; const PjWarp *warps; } PjMapInfo;", + "typedef struct { unsigned int tile_base; unsigned char frames; } PjSprite;", + "", + `#define PJ_MAP_COUNT ${model.maps.length}`, + `#define PJ_SPRITE_COUNT ${ctx.spriteProtos.length}`, + `#define PJ_TEXT_COUNT ${texts.length}`, + `#define PJ_SCRIPT_COUNT ${ctx.scripts.length}`, + `#define PJ_START_MAP ${model.start.map}`, + `#define PJ_START_X ${model.start.x}`, + `#define PJ_START_Y ${model.start.y}`, + `#define PJ_START_DIR ${model.start.dir}`, + `#define PJ_BG_TILE_COUNT ${bgTiles.length}`, + `#define PJ_OBJ_TILE_COUNT ${objTileCount}`, + `#define PJ_BOX_TILE ${boxTile}`, + `#define PJ_SLOT_BASE ${slotBase}`, + `#define PJ_FULL_GLYPH_COUNT ${ctx.fullGlyphs.size}`, + `#define PJ_SCRIPT_BUF ${GB_SCRIPT_BUF}`, + "", + "extern const PjMapInfo pj_maps[];", + "extern const PjSprite pj_sprites[];", + ...model.maps.flatMap((m) => [ + `extern const unsigned char pj_map${m.index}_tiles[]; BANKREF_EXTERN(pj_map${m.index}_tiles)`, + `extern const unsigned char pj_map${m.index}_coll[];`, + `extern const unsigned char pj_map${m.index}_actors[];`, + `extern const unsigned char pj_map${m.index}_warps[];`, + ]), + "extern const unsigned char pj_bg_tiles[]; BANKREF_EXTERN(pj_bg_tiles)", + "extern const unsigned char pj_obj_tiles[]; BANKREF_EXTERN(pj_obj_tiles)", + "extern const unsigned char pj_glyphs_half[]; BANKREF_EXTERN(pj_glyphs_half)", + "extern const unsigned char pj_glyphs_full[]; BANKREF_EXTERN(pj_glyphs_full)", + "extern const unsigned int pj_text_offs[]; BANKREF_EXTERN(pj_text_offs)", + "extern const unsigned char pj_texts[]; BANKREF_EXTERN(pj_texts)", + "extern const unsigned int pj_script_offs[]; BANKREF_EXTERN(pj_script_offs)", + "extern const unsigned char pj_scripts[]; BANKREF_EXTERN(pj_scripts)", + "", + "#endif", + "", + ].join("\n"); + + return { files, header }; +} + +// --------------------------------------------------------------------------- +// Build +// --------------------------------------------------------------------------- +export async function buildGb(out: CompileOutput, outPath: string): Promise { + const { files, header } = lowerGb(out); + await mkdir(RT + "/gen", { recursive: true }); + await Bun.write(`${RT}/gen_data.h`, header); + const genSources: string[] = []; + for (const [name, contents] of Object.entries(files)) { + const p = `${RT}/gen/${name}`; + await Bun.write(p, contents); + genSources.push(p); + } + + const runtimeSources = ["main", "gbrt", "vm", "textbox"].map((m) => `${RT}/${m}.c`); + const lcc = `${GBDK}/bin/lcc`; + const flags = [ + "-Wa-l", + "-Wl-m", + "-Wm-yt0x19", // MBC5 + "-Wm-yoA", // auto ROM bank count + "-Wm-ya0", + `-Wm-yn${out.game.title.replace(/[^A-Za-z0-9 ]/g, "").slice(0, 15) || "POCKETJS"}`, + "-autobank", + `-I${RT}`, + ]; + await $`${lcc} ${flags} -o ${outPath} ${runtimeSources} ${genSources}`.quiet(); + const size = (await Bun.file(outPath).arrayBuffer()).byteLength; + return { rom: outPath, size }; +} diff --git a/aot/compiler/targets/gba.ts b/aot/compiler/targets/gba.ts new file mode 100644 index 0000000..52ff8f8 --- /dev/null +++ b/aot/compiler/targets/gba.ts @@ -0,0 +1,291 @@ +// aot/compiler/targets/gba.ts — the GBA backend: encode neutral assets to +// 4bpp/BGR555, serialize PJGB chunks, pack the container, link the ROM. + +import { + BG_TILE_BUDGET, + BUDGET, + ByteWriter, + CHUNK, + GAME_TITLE_LEN, + GLYPH_STORE_HEADER_SIZE, + HALF_GLYPH_COUNT, + OBJ_TILE_BUDGET, + TEXT_MODE, + TILE_4BPP_BYTES, + TOK_ASCII_MIN, + rgb555, +} from "../../spec/pjgb.ts"; +import { FIRST_CHAR, LAST_CHAR, glyphPixels } from "../font.ts"; +import { halfcellPixels, unifontGlyph } from "../cjk.ts"; +import { tokenize } from "../text.ts"; +import { packCart, type Chunk } from "../pack.ts"; +import { buildRom, type BuildRomResult } from "../rom.ts"; +import type { CompileOutput } from "../index.ts"; +import type { Ctx } from "../context.ts"; +import type { GameModel } from "../model.ts"; + +// GBA 4bpp tile: 32 bytes, 4 bytes/row, low nibble = left pixel. +export function tile4(px: number[]): Uint8Array { + const out = new Uint8Array(TILE_4BPP_BYTES); + for (let row = 0; row < 8; row++) { + for (let c = 0; c < 4; c++) { + const lo = px[row * 8 + c * 2] & 0xf; + const hi = px[row * 8 + c * 2 + 1] & 0xf; + out[row * 4 + c] = lo | (hi << 4); + } + } + return out; +} + +// Textbox palette (BG bank 15): 1..5 are subpixel coverage ink shades, +// 6 is the opaque textbox background. +const TEXT_INK_START = 1; +const TEXT_INK_LEVELS = 5; +const TEXT_BG = 6; +const CJK_INK = TEXT_INK_START + TEXT_INK_LEVELS - 1; // brightest shade + +interface GbaEncoded { + bgPalette: Uint16Array; + objPalette: Uint16Array; + bgTiles: Uint8Array[]; + objTiles: Uint8Array[]; + glyphStore: Uint8Array | null; + fontBase: number; + boxTile: number; + glyphSlotBase: number; + glyphSlotCount: number; + bgTileIndexCount: number; // including reserved (dataless) slot tiles +} + +function encode(out: CompileOutput): GbaEncoded { + const { ctx, mode } = out; + const bgPalette = new Uint16Array(256); + const objPalette = new Uint16Array(256); + + ctx.bgPaletteRgb.forEach((rgb, i) => { + if (i < 16) bgPalette[i] = rgb555(rgb[0], rgb[1], rgb[2]); + }); + bgPalette[240 + 0] = rgb555(0, 0, 0); + bgPalette[240 + 1] = rgb555(76, 88, 132); + bgPalette[240 + 2] = rgb555(116, 128, 168); + bgPalette[240 + 3] = rgb555(160, 170, 204); + bgPalette[240 + 4] = rgb555(206, 214, 238); + bgPalette[240 + 5] = rgb555(248, 248, 248); + bgPalette[240 + TEXT_BG] = rgb555(24, 32, 72); + + const bgTiles: Uint8Array[] = ctx.bgTilePx.map(tile4); + + let fontBase = 0; + if (mode === "ascii8") { + fontBase = bgTiles.length; + for (let ch = FIRST_CHAR; ch <= LAST_CHAR; ch++) { + bgTiles.push(tile4(glyphPixels(ch, TEXT_INK_START, TEXT_BG, TEXT_INK_LEVELS))); + } + } + const boxTile = bgTiles.length; + bgTiles.push(tile4(new Array(64).fill(TEXT_BG))); + + // cjk16: reserve a dataless slot region right after the box tile; the + // runtime streams glyph tiles into these VRAM indices on demand. + let glyphSlotBase = 0; + let glyphSlotCount = 0; + if (mode === "cjk16") { + glyphSlotBase = bgTiles.length; + glyphSlotCount = ctx.target.glyphSlots * 2; + } + const bgTileIndexCount = bgTiles.length + glyphSlotCount; + + // Glyph store: 95 halfwidth ASCII + the game's fullwidth set, 4bpp. + let glyphStore: Uint8Array | null = null; + if (mode === "cjk16") { + const w = new ByteWriter(); + const full = ctx.fullGlyphs.list(); + w.u16(HALF_GLYPH_COUNT).u16(full.length).u16(TILE_4BPP_BYTES).u16(0); + for (let c = TOK_ASCII_MIN; c <= 0x7e; c++) { + const g = unifontGlyph(c); + const [top, bottom] = halfcellPixels(g, 0, CJK_INK, TEXT_BG); + w.bytes(tile4(top)).bytes(tile4(bottom)); + } + for (const ch of full) { + const g = unifontGlyph(ch.codePointAt(0)!); + for (const half of [0, 1] as const) { + const [top, bottom] = halfcellPixels(g, half, CJK_INK, TEXT_BG); + w.bytes(tile4(top)).bytes(tile4(bottom)); + } + } + glyphStore = w.toUint8Array(); + } + + // OBJ: 16x16 frames -> 4 tiles (TL, TR, BL, BR; 1D mapping), one palette + // bank per sprite. + const objTiles: Uint8Array[] = []; + ctx.spriteProtos.forEach((sp, si) => { + sp.palette.forEach((rgb, i) => { + if (i < 16) objPalette[si * 16 + i] = rgb555(rgb[0], rgb[1], rgb[2]); + }); + for (const grid of ctx.spriteFrames16[si]) { + for (const [ox, oy] of [ + [0, 0], + [8, 0], + [0, 8], + [8, 8], + ]) { + const t: number[] = []; + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) t.push(grid[(oy + y) * 16 + (ox + x)]); + objTiles.push(tile4(t)); + } + } + }); + + return { bgPalette, objPalette, bgTiles, objTiles, glyphStore, fontBase, boxTile, glyphSlotBase, glyphSlotCount, bgTileIndexCount }; +} + +// --- validation -------------------------------------------------------------- +function validate(out: CompileOutput, enc: GbaEncoded): void { + const { ctx, model } = out; + const err: string[] = []; + const t = ctx.target; + if (model.maps.length > BUDGET.MAX_MAPS) err.push(`too many maps (${model.maps.length} > ${BUDGET.MAX_MAPS})`); + if (ctx.spriteProtos.length > BUDGET.MAX_SPRITES) err.push(`too many sprites`); + if (ctx.flags.size > BUDGET.MAX_FLAGS) err.push(`too many flags (${ctx.flags.size} > ${BUDGET.MAX_FLAGS})`); + if (ctx.vars.size > BUDGET.MAX_VARS) err.push(`too many vars (${ctx.vars.size} > ${BUDGET.MAX_VARS})`); + if (ctx.texts.size > BUDGET.MAX_TEXTS) err.push(`too many texts`); + if (ctx.scripts.length > BUDGET.MAX_SCRIPTS) err.push(`too many scripts`); + if (ctx.fullGlyphs.size > BUDGET.MAX_FULL_GLYPHS) err.push(`too many unique CJK glyphs (${ctx.fullGlyphs.size})`); + if (enc.bgTileIndexCount > BG_TILE_BUDGET.gba) err.push(`BG tiles ${enc.bgTileIndexCount} > ${BG_TILE_BUDGET.gba}`); + if (enc.objTiles.length > OBJ_TILE_BUDGET.gba) err.push(`OBJ tiles ${enc.objTiles.length} > ${OBJ_TILE_BUDGET.gba}`); + for (const m of model.maps) { + if (m.w > t.maxMapW || m.h > t.maxMapH) err.push(`map "${m.name}" ${m.w}x${m.h} exceeds ${t.maxMapW}x${t.maxMapH}`); + if (m.actors.length > BUDGET.MAX_ACTORS_PER_MAP) err.push(`map "${m.name}" has ${m.actors.length} actors`); + for (const a of m.actors) { + if (a.onTalk !== 0xffff && a.onTalk >= ctx.scripts.length) err.push(`actor "${a.name}" -> bad script ${a.onTalk}`); + } + for (const wp of m.warps) { + if (wp.destMapIdx === undefined) err.push(`unresolved warp on "${m.name}"`); + } + } + if (err.length) throw new Error("IR validation failed:\n - " + err.join("\n - ")); +} + +// --- serializers -------------------------------------------------------------- +function u16buf(a: Uint16Array): Uint8Array { + const w = new ByteWriter(); + for (const v of a) w.u16(v); + return w.toUint8Array(); +} +function catTiles(ts: Uint8Array[]): Uint8Array { + const out = new Uint8Array(ts.length * TILE_4BPP_BYTES); + ts.forEach((t, i) => out.set(t, i * TILE_4BPP_BYTES)); + return out; +} + +function gameHeader(out: CompileOutput, enc: GbaEncoded): Uint8Array { + const { ctx, model, game } = out; + const w = new ByteWriter(); + w.ascii(game.title.replace(/[^\x20-\x7e]/g, "?"), GAME_TITLE_LEN); + w.u8(model.start.map).u8(model.start.dir).u16(model.start.x).u16(model.start.y); + w.u8(model.maps.length).u8(ctx.spriteProtos.length); + w.u16(ctx.flags.size).u16(ctx.texts.size).u16(ctx.scripts.length); + w.u16(enc.fontBase).u16(enc.boxTile); + w.u8(out.mode === "cjk16" ? TEXT_MODE.CJK16 : TEXT_MODE.ASCII8).u8(0); + w.u16(enc.glyphSlotBase).u16(enc.glyphSlotCount); + return w.toUint8Array(); +} + +function spriteTable(ctx: Ctx): Uint8Array { + const w = new ByteWriter(); + for (const s of ctx.spriteProtos) { + w.u16(s.tileBase * 4).u8(s.w).u8(s.h).u8(s.palbank).u8(s.frames).u16(0); + } + return w.toUint8Array(); +} + +function mapChunk(m: GameModel["maps"][number]): Uint8Array { + const HDR = 28; + const tilesOff = HDR; + const collOff = tilesOff + m.tiles.length * 2; + const actorsOff = (collOff + m.collision.length + 3) & ~3; + const warpsOff = actorsOff + m.actors.length * 12; + const w = new ByteWriter(); + w.u16(m.w).u16(m.h).u16(m.actors.length).u16(m.warps.length); + w.u8(m.palbank).u8(m.onEnter).u16(0); + w.u32(tilesOff).u32(collOff).u32(actorsOff).u32(warpsOff); + for (const t of m.tiles) w.u16(t); + for (const c of m.collision) w.u8(c); + w.align4(); + for (const a of m.actors) { + w.u16(a.x).u16(a.y).u8(a.spriteId).u8(a.facing).u8(a.movement).u8(a.flags).u16(a.onTalk).u16(0); + } + for (const wp of m.warps) { + w.u16(wp.x).u16(wp.y).u8(wp.destMapIdx!).u8(wp.destDir!).u16(wp.destX!).u16(wp.destY!).u16(0); + } + return w.toUint8Array(); +} + +function textBank(out: CompileOutput): Uint8Array { + const strs = out.ctx.texts.list(); + const enc = strs.map((s) => { + if (out.mode === "cjk16") return Uint8Array.from(tokenize(s, out.ctx.fullGlyphId)); + const b = new Uint8Array(s.length + 1); + for (let i = 0; i < s.length; i++) b[i] = s.charCodeAt(i) & 0x7f; + return b; + }); + const headerSize = 4 + strs.length * 4; + let cur = headerSize; + const offsets = enc.map((b) => { + const o = cur; + cur += b.length; + return o; + }); + const w = new ByteWriter(); + w.u16(strs.length).u16(0); + for (const o of offsets) w.u32(o); + for (const b of enc) w.bytes(b); + return w.toUint8Array(); +} + +function scriptChunks(ctx: Ctx): { code: Uint8Array; table: Uint8Array } { + const code = new ByteWriter(); + const table = new ByteWriter(); + for (const s of ctx.scripts) { + table.u32(code.length); + code.bytes(s.bytecode); + } + return { code: code.toUint8Array(), table: table.toUint8Array() }; +} + +// --- entry -------------------------------------------------------------------- +export function lowerGba(out: CompileOutput): { chunks: Chunk[]; blob: Uint8Array } { + // Tokenize texts FIRST so the fullwidth glyph set is complete before the + // glyph store is built. + const texts = textBank(out); + const encFinal = encode(out); + validate(out, encFinal); + + // Stash renderer ids for debugInfo consumers. + out.ctx.fontBase = encFinal.fontBase; + out.ctx.boxTile = encFinal.boxTile; + out.ctx.glyphSlotBase = encFinal.glyphSlotBase; + + const { code, table } = scriptChunks(out.ctx); + const chunks: Chunk[] = [ + { kind: CHUNK.GAME, id: 0, data: gameHeader(out, encFinal) }, + { kind: CHUNK.PAL_BG, id: 0, data: u16buf(encFinal.bgPalette) }, + { kind: CHUNK.PAL_OBJ, id: 0, data: u16buf(encFinal.objPalette) }, + { kind: CHUNK.TILES_BG, id: 0, data: catTiles(encFinal.bgTiles) }, + { kind: CHUNK.TILES_OBJ, id: 0, data: catTiles(encFinal.objTiles) }, + { kind: CHUNK.SPRITE_TABLE, id: 0, data: spriteTable(out.ctx) }, + { kind: CHUNK.TEXT_BANK, id: 0, data: texts }, + { kind: CHUNK.SCRIPT_CODE, id: 0, data: code }, + { kind: CHUNK.SCRIPT_TABLE, id: 0, data: table }, + ]; + if (encFinal.glyphStore) chunks.push({ kind: CHUNK.GLYPHS, id: 0, data: encFinal.glyphStore }); + for (const m of out.model.maps) chunks.push({ kind: CHUNK.MAP, id: m.index, data: mapChunk(m) }); + return { chunks, blob: packCart(chunks) }; +} + +export async function buildGba(out: CompileOutput, outPath: string): Promise { + const { blob } = lowerGba(out); + const r = await buildRom(blob, outPath, out.game.title); + return { ...r, blob }; +} diff --git a/aot/compiler/targets/index.ts b/aot/compiler/targets/index.ts new file mode 100644 index 0000000..68aa515 --- /dev/null +++ b/aot/compiler/targets/index.ts @@ -0,0 +1,26 @@ +// aot/compiler/targets/index.ts — target backend dispatch. + +import type { CompileOutput } from "../index.ts"; + +export interface TargetBuildResult { + rom: string; + size: number; +} + +export async function buildTarget(out: CompileOutput, outPath: string): Promise { + switch (out.target) { + case "gba": { + const { buildGba } = await import("./gba.ts"); + const r = await buildGba(out, outPath); + return { rom: r.gba, size: r.size }; + } + case "gb": { + const { buildGb } = await import("./gb.ts"); + return buildGb(out, outPath); + } + case "nes": { + const { buildNes } = await import("./nes.ts"); + return buildNes(out, outPath); + } + } +} diff --git a/aot/compiler/targets/nes.ts b/aot/compiler/targets/nes.ts new file mode 100644 index 0000000..5e2fed8 --- /dev/null +++ b/aot/compiler/targets/nes.ts @@ -0,0 +1,483 @@ +// aot/compiler/targets/nes.ts — the NES backend. +// +// Like the GB backend, all game data is residualized into C arrays; unlike +// the GB (whose linker autobanks), the COMPILER assigns UNROM banks here: +// big read-only blobs (glyphs, tiles, texts, per-map tiles) are first-fit +// packed into 16 KB switchable banks (segments BANK0..n at $8000), while +// code, collision (bit-packed), actors/warps, scripts, and the sprite/map +// tables live in the fixed bank ($C000). The linker config and iNES header +// are generated to match. +// +// Pixel policy: the authored 16-color art is luminance-clustered to the +// NES's 4-color palettes — BG palette 0 for the map (backdrop = darkest +// cluster), palette 1 for the textbox, and up to 4 OBJ palettes deduped +// across sprites. + +import { mkdir } from "node:fs/promises"; +import { $ } from "bun"; +import { + BG_TILE_BUDGET, + BUDGET, + OBJ_TILE_BUDGET, + TILE_2BPP_BYTES, + TOK_ASCII_MIN, +} from "../../spec/pjgb.ts"; +import { halfcellPixels, unifontGlyph } from "../cjk.ts"; +import { tokenize } from "../text.ts"; +import { shadeOf, tileShadeMapper } from "./gb.ts"; +import type { CompileOutput } from "../index.ts"; +import type { Rgb } from "../context.ts"; +import type { TargetBuildResult } from "./index.ts"; + +const ROOT = new URL("../../..", import.meta.url).pathname; +const RT = ROOT + "aot/runtime/nes"; +const BANK_SIZE = 16 * 1024; +const BANK_CAP = BANK_SIZE - 64; // headroom for alignment/slop + +// The canonical 2C02 master palette (NesDev wiki reference values). +const NES_PAL: Rgb[] = [ + [84, 84, 84], [0, 30, 116], [8, 16, 144], [48, 0, 136], [68, 0, 100], [92, 0, 48], [84, 4, 0], [60, 24, 0], + [32, 42, 0], [8, 58, 0], [0, 64, 0], [0, 60, 0], [0, 50, 60], [0, 0, 0], [0, 0, 0], [0, 0, 0], + [152, 150, 152], [8, 76, 196], [48, 50, 236], [92, 30, 228], [136, 20, 176], [160, 20, 100], [152, 34, 32], [120, 60, 0], + [84, 90, 0], [40, 114, 0], [8, 124, 0], [0, 118, 40], [0, 102, 120], [0, 0, 0], [0, 0, 0], [0, 0, 0], + [236, 238, 236], [76, 154, 236], [120, 124, 236], [176, 98, 236], [228, 84, 236], [236, 88, 180], [236, 106, 100], [212, 136, 32], + [160, 170, 0], [116, 196, 0], [76, 208, 32], [56, 204, 108], [56, 180, 204], [60, 60, 60], [0, 0, 0], [0, 0, 0], + [236, 238, 236], [168, 204, 236], [188, 188, 236], [212, 178, 236], [236, 174, 236], [236, 174, 212], [236, 180, 176], [228, 196, 144], + [204, 210, 120], [180, 222, 120], [168, 226, 144], [152, 226, 180], [160, 214, 228], [160, 162, 160], [0, 0, 0], [0, 0, 0], +]; + +function nearestNes(c: Rgb): number { + let best = 0x0f; + let bestD = Infinity; + NES_PAL.forEach((p, i) => { + if ((i & 0x0f) >= 0x0e) return; // skip the black mirrors + const d = (p[0] - c[0]) ** 2 + (p[1] - c[1]) ** 2 + (p[2] - c[2]) ** 2; + if (d < bestD) { + bestD = d; + best = i; + } + }); + return best; +} + +function avg(colors: Rgb[]): Rgb { + if (!colors.length) return [128, 128, 128]; + const s = colors.reduce((a, c) => [a[0] + c[0], a[1] + c[1], a[2] + c[2]] as Rgb, [0, 0, 0] as Rgb); + return [s[0] / colors.length, s[1] / colors.length, s[2] / colors.length]; +} + +/** NES planar 2bpp tile: 8 bytes plane 0 then 8 bytes plane 1. */ +export function tile2nes(px: number[], toVal: (v: number, x: number, y: number) => number): Uint8Array { + const out = new Uint8Array(TILE_2BPP_BYTES); + for (let row = 0; row < 8; row++) { + let p0 = 0; + let p1 = 0; + for (let x = 0; x < 8; x++) { + const v = toVal(px[row * 8 + x], x, row) & 3; + p0 |= (v & 1) << (7 - x); + p1 |= ((v >> 1) & 1) << (7 - x); + } + out[row] = p0; + out[row + 8] = p1; + } + return out; +} + +// Textbox glyphs in BG palette 1: bg = color 1 (black), ink = color 3 (white). +const GLYPH_INK = 3; +const GLYPH_BG = 1; + +function cBytes(name: string, bytes: ArrayLike): string { + const rows: string[] = []; + for (let i = 0; i < bytes.length; i += 16) { + const slice: string[] = []; + for (let j = i; j < Math.min(i + 16, bytes.length); j++) slice.push(`0x${(bytes[j] & 0xff).toString(16).padStart(2, "0")}`); + rows.push(" " + slice.join(",") + ","); + } + return [`const unsigned char ${name}[] = {`, ...rows, "};"].join("\n"); +} +function cWords(name: string, words: number[]): string { + const rows: string[] = []; + for (let i = 0; i < words.length; i += 12) { + rows.push(" " + words.slice(i, i + 12).map((w) => `0x${(w & 0xffff).toString(16)}`).join(",") + ","); + } + return [`const unsigned int ${name}[] = {`, ...rows, "};"].join("\n"); +} + +interface BankItem { + name: string; // symbol-ish label + code: string; // C source for this item's arrays + size: number; + symbols: string[]; // symbols defined (for PJ_BANK_* defines) +} + +export async function buildNes(out: CompileOutput, outPath: string): Promise { + const { ctx, model, game } = out; + const t = ctx.target; + + // --- text bank first (completes the glyph set) --- + const texts = ctx.texts.list().map((s) => tokenize(s, ctx.fullGlyphId)); + const textBlob: number[] = []; + const textOffs: number[] = []; + for (const tk of texts) { + textOffs.push(textBlob.length); + textBlob.push(...tk); + } + + // --- palettes --- + const bgClasses: Rgb[][] = [[], [], [], []]; + ctx.bgPaletteRgb.forEach((c) => bgClasses[shadeOf(c)].push(c)); + const bgRep = (cls: number, fallback: Rgb): number => nearestNes(bgClasses[cls].length ? avg(bgClasses[cls]) : fallback); + const pal0 = [ + bgRep(3, [20, 20, 20]), + bgRep(2, [88, 88, 88]), + bgRep(1, [160, 160, 160]), + bgRep(0, [236, 238, 236]), + ]; + const palBox = [pal0[0], 0x0f, 0x2d, 0x30]; + + // OBJ palettes: per sprite, dedupe to <= 4 + const objPals: number[][] = []; + const spritePal: number[] = []; + ctx.spriteProtos.forEach((sp) => { + const cls: Rgb[][] = [[], [], []]; // slots 1..3 (light, mid, dark) + sp.palette.forEach((c, i) => { + if (i === 0) return; + const s = shadeOf(c); + cls[s === 0 ? 0 : s - 1 > 2 ? 2 : s - 1].push(c); + }); + const p = [ + pal0[0], + nearestNes(cls[0].length ? avg(cls[0]) : [236, 238, 236]), + nearestNes(cls[1].length ? avg(cls[1]) : [136, 136, 136]), + nearestNes(cls[2].length ? avg(cls[2]) : [32, 32, 32]), + ]; + const key = p.join(","); + let idx = objPals.findIndex((q) => q.join(",") === key); + if (idx < 0) { + if (objPals.length < 4) { + objPals.push(p); + idx = objPals.length - 1; + } else { + // merge into the nearest existing palette + let best = 0; + let bestD = Infinity; + objPals.forEach((q, qi) => { + let d = 0; + for (let k = 1; k < 4; k++) { + const a = NES_PAL[q[k]]; + const b = NES_PAL[p[k]]; + d += (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2; + } + if (d < bestD) { + bestD = d; + best = qi; + } + }); + idx = best; + } + } + spritePal.push(idx); + }); + while (objPals.length < 4) objPals.push(palBox); + + const palettes = [ + pal0[0], pal0[1], pal0[2], pal0[3], + palBox[0], palBox[1], palBox[2], palBox[3], + palBox[0], palBox[1], palBox[2], palBox[3], + palBox[0], palBox[1], palBox[2], palBox[3], + ...objPals.flat(), + ]; + + // --- BG tiles --- + const bgTiles: Uint8Array[] = ctx.bgTilePx.map((px) => { + const shade = tileShadeMapper(px, ctx.bgPaletteRgb); + return tile2nes(px, (v, x, y) => 3 - shade(v, x, y)); + }); + const boxTile = bgTiles.length; + bgTiles.push(tile2nes(new Array(64).fill(0), () => GLYPH_BG)); + const slotBase = bgTiles.length; + const slotTiles = t.glyphSlots * 2; + + // --- glyphs --- + const glyphHalf: number[] = []; + for (let c = TOK_ASCII_MIN; c <= 0x7e; c++) { + const [top, bottom] = halfcellPixels(unifontGlyph(c), 0, GLYPH_INK, GLYPH_BG); + glyphHalf.push(...tile2nes(top, (v) => v), ...tile2nes(bottom, (v) => v)); + } + const glyphFull: number[] = []; + for (const ch of ctx.fullGlyphs.list()) { + const gl = unifontGlyph(ch.codePointAt(0)!); + for (const half of [0, 1] as const) { + const [top, bottom] = halfcellPixels(gl, half, GLYPH_INK, GLYPH_BG); + glyphFull.push(...tile2nes(top, (v) => v), ...tile2nes(bottom, (v) => v)); + } + } + + // --- OBJ tiles: per frame L(top,bottom), R(top,bottom) --- + const objTiles: number[] = []; + ctx.spriteProtos.forEach((sp, si) => { + const pal = sp.palette; + const objVal = (v: number): number => { + if (v === 0) return 0; + const s = shadeOf(pal[v] ?? [0, 0, 0]); + return s === 0 ? 1 : s; + }; + for (const grid of ctx.spriteFrames16[si]) { + for (const [ox, oy] of [ + [0, 0], + [0, 8], + [8, 0], + [8, 8], + ]) { + const tpx: number[] = []; + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) tpx.push(grid[(oy + y) * 16 + (ox + x)]); + objTiles.push(...tile2nes(tpx, objVal)); + } + } + }); + const objTileCount = objTiles.length / TILE_2BPP_BYTES; + + // --- validation --- + const err: string[] = []; + if (slotBase + slotTiles > BG_TILE_BUDGET.nes) err.push(`BG tiles ${slotBase}+${slotTiles} slots > ${BG_TILE_BUDGET.nes}`); + if (objTileCount > OBJ_TILE_BUDGET.nes) err.push(`OBJ tiles ${objTileCount} > ${OBJ_TILE_BUDGET.nes}`); + if (ctx.fullGlyphs.size > BUDGET.MAX_FULL_GLYPHS) err.push(`too many CJK glyphs (${ctx.fullGlyphs.size})`); + for (const m of model.maps) { + if (m.w > t.maxMapW || m.h > t.maxMapH) err.push(`map "${m.name}" ${m.w}x${m.h} exceeds ${t.maxMapW}x${t.maxMapH} (NES nametable)`); + if (m.actors.length > BUDGET.MAX_ACTORS_PER_MAP) err.push(`map "${m.name}" has ${m.actors.length} actors`); + } + if (err.length) throw new Error("NES lowering failed:\n - " + err.join("\n - ")); + + // --- banked items (first-fit) --- + const items: BankItem[] = []; + const pushBytes = (sym: string, bytes: ArrayLike): void => { + items.push({ name: sym, code: cBytes(sym, bytes.length ? bytes : [0]), size: Math.max(bytes.length, 1), symbols: [sym] }); + }; + pushBytes("pj_glyphs_full", glyphFull); + pushBytes("pj_glyphs_half", glyphHalf); + pushBytes("pj_bg_tiles", bgTiles.flatMap((b) => [...b])); + pushBytes("pj_obj_tiles", objTiles); + items.push({ + name: "pj_texts", + code: [cWords("pj_text_offs", textOffs.length ? textOffs : [0]), "", cBytes("pj_texts", textBlob.length ? textBlob : [0])].join("\n"), + size: textBlob.length + textOffs.length * 2 + 2, + symbols: ["pj_texts", "pj_text_offs"], + }); + model.maps.forEach((m) => { + pushBytes(`pj_map${m.index}_tiles`, m.tiles); + }); + + const banks: BankItem[][] = []; + const bankOf = new Map(); + for (const it of items) { + if (it.size > BANK_CAP) throw new Error(`NES: data item ${it.name} (${it.size}B) exceeds a 16KB bank`); + let placed = -1; + for (let b = 0; b < banks.length; b++) { + const used = banks[b].reduce((a, x) => a + x.size, 0); + if (used + it.size <= BANK_CAP) { + placed = b; + break; + } + } + if (placed < 0) { + banks.push([]); + placed = banks.length - 1; + } + banks[placed].push(it); + for (const s of it.symbols) bankOf.set(s, placed); + } + const nDataBanks = banks.length; + let prgTotal = 2; + while (prgTotal < nDataBanks + 1) prgTotal *= 2; + if (prgTotal > 16) throw new Error(`NES: game needs ${nDataBanks + 1} PRG banks (> UNROM's 16)`); + + // --- fixed-bank data --- + const scriptBlob: number[] = []; + const scriptOffs: number[] = []; + for (const s of ctx.scripts) { + scriptOffs.push(scriptBlob.length); + scriptBlob.push(...s.bytecode); + } + scriptOffs.push(scriptBlob.length); + + const fixedParts: string[] = []; + fixedParts.push(cBytes("pj_palettes", palettes)); + fixedParts.push(cWords("pj_script_offs", scriptOffs)); + fixedParts.push(cBytes("pj_scripts", scriptBlob.length ? scriptBlob : [0])); + const mapInfos: string[] = []; + model.maps.forEach((m) => { + // bit-packed collision + const packed = new Array(Math.ceil((m.w * m.h) / 8)).fill(0); + m.collision.forEach((c, i) => { + if (c) packed[i >> 3] |= 1 << (i & 7); + }); + const actors: number[] = []; + for (const a of m.actors) { + actors.push(a.x & 0xff, a.x >> 8, a.y & 0xff, a.y >> 8, a.spriteId, a.facing, a.movement, a.flags, a.onTalk & 0xff, (a.onTalk >> 8) & 0xff, 0, 0); + } + const warps: number[] = []; + for (const wp of m.warps) { + warps.push(wp.x & 0xff, wp.x >> 8, wp.y & 0xff, wp.y >> 8, wp.destMapIdx!, wp.destDir!, wp.destX! & 0xff, wp.destX! >> 8, wp.destY! & 0xff, wp.destY! >> 8, 0, 0); + } + fixedParts.push(cBytes(`pj_map${m.index}_coll`, packed)); + fixedParts.push(cBytes(`pj_map${m.index}_actors`, actors.length ? actors : [0])); + fixedParts.push(cBytes(`pj_map${m.index}_warps`, warps.length ? warps : [0])); + mapInfos.push( + ` { ${m.w}, ${m.h}, 0x${m.onEnter.toString(16)}, ${m.actors.length}, ${m.warps.length}, ${bankOf.get(`pj_map${m.index}_tiles`)}, ` + + `pj_map${m.index}_tiles, pj_map${m.index}_coll, (const PjActor *)pj_map${m.index}_actors, (const PjWarp *)pj_map${m.index}_warps },`, + ); + }); + fixedParts.push(`const PjMapInfo pj_maps[] = {\n${mapInfos.join("\n")}\n};`); + fixedParts.push( + `const PjSprite pj_sprites[] = {\n` + + (ctx.spriteProtos.map((sp, i) => ` { ${sp.tileBase * 4}, ${sp.frames}, ${spritePal[i]} },`).join("\n") || " { 0, 1, 0 },") + + `\n};`, + ); + + // --- gen_data.h --- + const header = [ + "/* GENERATED by @pocketjs/aot (nes backend) — do not edit. */", + "#ifndef PJ_GEN_DATA_H", + "#define PJ_GEN_DATA_H", + "", + "typedef struct { unsigned int x, y; unsigned char sprite, facing, move, flags; unsigned int on_talk, rsv; } PjActor;", + "typedef struct { unsigned int x, y; unsigned char dest_map, dest_dir; unsigned int dest_x, dest_y, rsv; } PjWarp;", + "typedef struct { unsigned char w, h, on_enter, n_actors, n_warps, tiles_bank;", + " const unsigned char *tiles; const unsigned char *coll;", + " const PjActor *actors; const PjWarp *warps; } PjMapInfo;", + "typedef struct { unsigned int tile_base; unsigned char frames; unsigned char pal; } PjSprite;", + "", + `#define PJ_MAP_COUNT ${model.maps.length}`, + `#define PJ_SPRITE_COUNT ${ctx.spriteProtos.length}`, + `#define PJ_TEXT_COUNT ${texts.length}`, + `#define PJ_SCRIPT_COUNT ${ctx.scripts.length}`, + `#define PJ_START_MAP ${model.start.map}`, + `#define PJ_START_X ${model.start.x}`, + `#define PJ_START_Y ${model.start.y}`, + `#define PJ_START_DIR ${model.start.dir}`, + `#define PJ_BG_TILE_COUNT ${bgTiles.length}`, + `#define PJ_OBJ_TILE_COUNT ${objTileCount}`, + `#define PJ_BOX_TILE ${boxTile}`, + `#define PJ_SLOT_BASE ${slotBase}`, + `#define PJ_FULL_GLYPH_COUNT ${ctx.fullGlyphs.size}`, + `#define PJ_BANK_GLYPHS_FULL ${bankOf.get("pj_glyphs_full")}`, + `#define PJ_BANK_GLYPHS_HALF ${bankOf.get("pj_glyphs_half")}`, + `#define PJ_BANK_BG_TILES ${bankOf.get("pj_bg_tiles")}`, + `#define PJ_BANK_OBJ_TILES ${bankOf.get("pj_obj_tiles")}`, + `#define PJ_BANK_TEXTS ${bankOf.get("pj_texts")}`, + "", + "extern const unsigned char pj_palettes[];", + "extern const PjMapInfo pj_maps[];", + "extern const PjSprite pj_sprites[];", + "extern const unsigned char pj_scripts[];", + "extern const unsigned int pj_script_offs[];", + "extern const unsigned char pj_glyphs_full[];", + "extern const unsigned char pj_glyphs_half[];", + "extern const unsigned char pj_bg_tiles[];", + "extern const unsigned char pj_obj_tiles[];", + "extern const unsigned char pj_texts[];", + "extern const unsigned int pj_text_offs[];", + ...model.maps.flatMap((m) => [ + `extern const unsigned char pj_map${m.index}_tiles[];`, + `extern const unsigned char pj_map${m.index}_coll[];`, + `extern const unsigned char pj_map${m.index}_actors[];`, + `extern const unsigned char pj_map${m.index}_warps[];`, + ]), + "", + "#endif", + "", + ].join("\n"); + + // --- generated linker config + iNES header --- + const memBanks = Array.from({ length: prgTotal - 1 }, (_, i) => ` PRG${i}: start=$8000, size=$4000, file=%O, fill=yes, fillval=$FF;`); + const segBanks = banks.map((_, i) => ` BANK${i}: load=PRG${i}, type=ro;`); + const cfg = [ + "MEMORY {", + " ZP: start=$0002, size=$001E, type=rw, define=yes;", + " MAIN: start=$0300, size=$0400, type=rw, define=yes;", + " HDR: start=$0000, size=$0010, file=%O, fill=yes;", + ...memBanks, + " PRGFIX: start=$C000, size=$3FFA, file=%O, fill=yes, fillval=$FF;", + " VEC: start=$FFFA, size=$0006, file=%O, fill=yes;", + "}", + "SEGMENTS {", + " HEADER: load=HDR, type=ro;", + ...segBanks, + " STARTUP: load=PRGFIX, type=ro, define=yes;", + " ONCE: load=PRGFIX, type=ro, optional=yes;", + " LOWCODE: load=PRGFIX, type=ro, optional=yes;", + " CODE: load=PRGFIX, type=ro, define=yes;", + " RODATA: load=PRGFIX, type=ro, define=yes;", + " DATA: load=PRGFIX, run=MAIN, type=rw, define=yes;", + " VECTORS: load=VEC, type=ro;", + " BSS: load=MAIN, type=bss, define=yes;", + " ZEROPAGE: load=ZP, type=zp;", + "}", + "", + ].join("\n"); + + const ines = [ + "; GENERATED iNES header (UNROM, CHR-RAM)", + '.segment "HEADER"', + `.byte $4E,$45,$53,$1A, ${prgTotal}, 0, $20, $00`, + ".byte 0,0,0,0,0,0,0,0", + "", + ].join("\n"); + + // --- write generated sources + build --- + const BUILD = ROOT + "aot/dist/nes-build"; + await mkdir(BUILD, { recursive: true }); + const gen: string[] = []; + const writeGen = async (name: string, contents: string): Promise => { + await Bun.write(`${BUILD}/${name}`, contents); + gen.push(name); + }; + await writeGen("gen_data.h", header); + await writeGen("gen_ines.s", ines); + await writeGen("gen_nes.cfg", cfg); + await writeGen( + "gen_fixed.c", + ['/* GENERATED by @pocketjs/aot (nes backend) */', '#include "gen_data.h"', "", ...fixedParts, ""].join("\n"), + ); + for (let b = 0; b < banks.length; b++) { + await writeGen( + `gen_bank${b}.c`, + [ + "/* GENERATED by @pocketjs/aot (nes backend) */", + `#pragma rodata-name ("BANK${b}")`, + "", + ...banks[b].map((it) => it.code), + "", + ].join("\n"), + ); + } + + const cc65home = + process.env.CC65_HOME ?? ((await $`brew --prefix cc65`.quiet().text().catch(() => "")).trim() || "/opt/homebrew/opt/cc65"); + const nesLib = `${cc65home}/share/cc65/lib/nes.lib`; + + const cFiles = [ + { src: `${RT}/nesrt.c`, o: "nesrt" }, + { src: `${RT}/vm.c`, o: "vm" }, + { src: `${RT}/textbox.c`, o: "textbox" }, + { src: `${RT}/main.c`, o: "main" }, + { src: `${BUILD}/gen_fixed.c`, o: "gen_fixed" }, + ...banks.map((_, b) => ({ src: `${BUILD}/gen_bank${b}.c`, o: `gen_bank${b}` })), + ]; + const objs: string[] = []; + for (const f of cFiles) { + await $`cc65 -t nes -Osir -I ${RT} -I ${BUILD} -o ${BUILD}/${f.o}.s ${f.src}`.quiet(); + await $`ca65 -t nes -o ${BUILD}/${f.o}.o ${BUILD}/${f.o}.s`.quiet(); + objs.push(`${BUILD}/${f.o}.o`); + } + for (const s of [`${RT}/crt0.s`, `${BUILD}/gen_ines.s`]) { + const o = `${BUILD}/${s.split("/").pop()!.replace(/\.s$/, "")}.o`; + await $`ca65 -t nes -o ${o} ${s}`.quiet(); + objs.unshift(o); + } + await $`ld65 -C ${BUILD}/gen_nes.cfg -o ${outPath} ${objs} ${nesLib}`.quiet(); + + const size = (await Bun.file(outPath).arrayBuffer()).byteLength; + return { rom: outPath, size }; +} diff --git a/aot/compiler/text.ts b/aot/compiler/text.ts new file mode 100644 index 0000000..c54b53c --- /dev/null +++ b/aot/compiler/text.ts @@ -0,0 +1,106 @@ +// aot/compiler/text.ts — compile-time text layout + tokenization (cjk16). +// +// The runtimes never measure text: the compiler wraps each string to the +// target's textbox metrics, splits it into pages (one OP_TEXT per page), and +// encodes each page as the token stream from spec/pjgb.ts. Newlines inside a +// page are explicit TOK_NEWLINE bytes. + +import { + TOK_ASCII_MAX, + TOK_ASCII_MIN, + TOK_END, + TOK_FULL_FLAG, + TOK_NEWLINE, + type TargetSpec, +} from "../spec/pjgb.ts"; +import { isFullwidth } from "./cjk.ts"; + +/** Halfcell width of one char (1 = halfwidth, 2 = fullwidth). */ +export function charCells(ch: string): number { + return isFullwidth(ch) ? 2 : 1; +} + +/** Halfcell width of a whole string. */ +export function textCells(s: string): number { + let n = 0; + for (const ch of s) n += charCells(ch); + return n; +} + +/** + * Wrap `text` to `cols` halfcells per line and split into pages of at most + * `lines` lines. Explicit "\n" in the source is honored. Breaking units: + * ASCII words stay whole (greedy word wrap), CJK chars break anywhere, and + * an over-long single word hard-breaks mid-word. + */ +export function wrapPages(text: string, spec: Pick): string[] { + const cols = spec.textCols; + const lines: string[] = []; + for (const src of text.split("\n")) { + // units: runs of printable ASCII (words), runs of spaces, single other chars + const units = src.match(/[\x21-\x7e]+| +|[^\x20-\x7e]/g) ?? []; + let line = ""; + let cells = 0; + const flush = (): void => { + lines.push(line.trimEnd()); + line = ""; + cells = 0; + }; + for (const u of units) { + const w = textCells(u); + if (u.startsWith(" ")) { + if (cells > 0 && cells + w <= cols) { + line += u; + cells += w; + } // leading/overflowing spaces are dropped at breaks + continue; + } + if (cells + w <= cols) { + line += u; + cells += w; + continue; + } + if (cells > 0) flush(); + if (w <= cols) { + line = u; + cells = w; + continue; + } + // single unit wider than the box: hard-break by chars + for (const ch of u) { + const cw = charCells(ch); + if (cells + cw > cols) flush(); + line += ch; + cells += cw; + } + } + flush(); + } + const pages: string[] = []; + for (let i = 0; i < lines.length; i += spec.textLines) { + pages.push(lines.slice(i, i + spec.textLines).join("\n")); + } + return pages.length ? pages : [""]; +} + +/** + * Encode one page into the cjk16 token stream. Non-ASCII chars intern a + * fullwidth glyph id through `fullGlyphId` (char -> dense id). + */ +export function tokenize(page: string, fullGlyphId: (ch: string) => number): number[] { + const out: number[] = []; + for (const ch of page) { + const cp = ch.codePointAt(0)!; + if (ch === "\n") { + out.push(TOK_NEWLINE); + } else if (cp >= TOK_ASCII_MIN && cp <= TOK_ASCII_MAX) { + out.push(cp); + } else { + const id = fullGlyphId(ch); + if (id > 0x3fff) throw new Error(`glyph id ${id} out of range for "${ch}"`); + out.push(TOK_FULL_FLAG | (id >> 8), id & 0xff); + } + } + out.push(TOK_END); + return out; +} diff --git a/aot/demo-shendiao/assets.generated.ts b/aot/demo-shendiao/assets.generated.ts new file mode 100644 index 0000000..18597ca --- /dev/null +++ b/aot/demo-shendiao/assets.generated.ts @@ -0,0 +1,1220 @@ +// GENERATED by build-assets.ts — do not edit by hand. +// Sources: aot/demo-shendiao/imagegen/*-source.png (placeholders where missing). + +export const WUXIA_PALETTE: [number, number, number][] = [[16,18,22],[96,144,88],[56,96,64],[140,144,148],[88,92,100],[52,84,132],[236,240,240],[172,176,176],[148,108,64],[92,64,40],[196,60,48],[232,148,48],[248,220,96],[120,116,108],[32,40,52],[24,26,30]]; + +export const WUXIA_TILES = { + "grass": { + "px": [ + "44444444", + "24224142", + "44442224", + "14444444", + "44442222", + "44444224", + "22214441", + "44442442" + ] + }, + "path": { + "px": [ + "4dddddd4", + "dddddddd", + "ddddddd4", + "4dddddd4", + "dddddddd", + "dddddddd", + "ddddddd4", + "4dddddd4" + ] + }, + "gravel": { + "px": [ + "ddddd22e", + "dddd22e2", + "ddd4222e", + "dd4222e2", + "ddd2222e", + "dd4222ee", + "dd422eee", + "d442eeee" + ] + }, + "flower": { + "px": [ + "44d24442", + "4dd44442", + "224443d4", + "24444442", + "43442444", + "42443424", + "242dd242", + "44222444" + ] + }, + "bridge": { + "px": [ + "eeeeee2e", + "e988849e", + "2988889e", + "e998899e", + "2988889e", + "2948849e", + "2988849e", + "efeeeeee" + ] + }, + "stairs": { + "px": [ + "24944944", + "49e99994", + "4e9999e4", + "4ee999e4", + "9ee999e2", + "ee9999fe", + "2ee99ef2", + "2ee999e2" + ] + }, + "cave_floor": { + "px": [ + "eeeeeeee", + "eeeeeeee", + "eeeeeeee", + "eeeeeeee", + "eeeeeeee", + "eeeeeeee", + "eeeeeeee", + "eeeeeeee" + ] + }, + "platform": { + "px": [ + "99999999", + "99999999", + "99999999", + "99999999", + "99999999", + "99999999", + "99999999", + "efeeeffe" + ] + }, + "gate": { + "px": [ + "44666644", + "e26666ee", + "e27376ee", + "ee2442ee", + "ee2442ee", + "ee2442ee", + "ee2442ee", + "24444442" + ] + }, + "cliff": { + "px": [ + "24444d24", + "4442d4e2", + "42ee42ee", + "2eee224e", + "22e2ee2e", + "e4eeee2e", + "eeeeeeee", + "eeeeeefe" + ], + "solid": true + }, + "chasm": { + "px": [ + "2effffe4", + "efffffe2", + "effffffe", + "effffff2", + "effffff2", + "eefffee4", + "24efe424", + "244444ee" + ], + "solid": true + }, + "water": { + "px": [ + "eeeeeeee", + "eeeeeeee", + "eeeeeeee", + "eeeeeeee", + "eeeeeeee", + "eeeeeeee", + "eeeeeeee", + "eeeeeeee" + ], + "solid": true + }, + "rapids": { + "px": [ + "2473372e", + "eed77444", + "23734224", + "243733d4", + "e247774e", + "4d333442", + "ed773334", + "4d3777de" + ], + "solid": true + }, + "pine": { + "px": [ + "1742d111", + "134422d7", + "42dd3dd3", + "1139d331", + "1d911111", + "13e4dd71", + "d2eeee2d", + "d422224d" + ], + "solid": true + }, + "tomb_wall": { + "px": [ + "eeeeeeee", + "2e222ee2", + "eeeeeeee", + "44444424", + "24444422", + "22442422", + "e2e22eee", + "eeeeeeee" + ], + "solid": true + }, + "stone_door": { + "px": [ + "222e2222", + "22444422", + "24444442", + "24444442", + "2444444e", + "e222222e", + "eeee2eee", + "22242422" + ], + "solid": true + }, + "sword_mound": { + "px": [ + "11177111", + "1114d111", + "11133111", + "113d4311", + "1d442231", + "d2242e24", + "222e22ee", + "3d422243" + ], + "solid": true + }, + "stele": { + "px": [ + "17444411", + "1d444431", + "1d444431", + "1d4442d1", + "1d2422d1", + "32222227", + "24444422", + "424e2224" + ], + "solid": true + }, + "city_wall": { + "px": [ + "eeeeeeee", + "37e33e33", + "22422d22", + "eeeeeeee", + "22222222", + "22222222", + "22222222", + "eeeeeeee" + ], + "solid": true + }, + "banner_song": { + "px": [ + "94e443d3", + "99999994", + "99999994", + "98888991", + "99999991", + "99999991", + "d3f9d991", + "11e11711" + ], + "solid": true + }, + "banner_mongol": { + "px": [ + "ded73d73", + "defeef33", + "49eeef71", + "d9eee43d", + "19efe427", + "19dee27d", + "141717e1", + "14111171" + ], + "solid": true + }, + "torch": { + "px": [ + "11117611", + "11bbbb11", + "9e9999e9", + "74eefe43", + "1349e4d1", + "1344e4d1", + "3ee9effd", + "73d2e433" + ], + "solid": true + }, + "fire": { + "px": [ + "11116111", + "11163111", + "11178611", + "118a8661", + "178bba71", + "388bb897", + "2e9999e2", + "34222247" + ], + "solid": true + }, + "tent": { + "px": [ + "111d1111", + "17d44311", + "33333dd1", + "4333dd21", + "44949227", + "ddeee44d", + "4defe424", + "d44d44d1" + ], + "solid": true + } +}; + +export const SPRITES = { + "hero": { + "palette": [ + [ + 0, + 0, + 0 + ], + [ + 58, + 70, + 60 + ], + [ + 40, + 48, + 44 + ], + [ + 232, + 200, + 168 + ], + [ + 30, + 30, + 34 + ], + [ + 80, + 84, + 92 + ], + [ + 244, + 244, + 236 + ], + [ + 24, + 24, + 26 + ], + [ + 140, + 120, + 96 + ], + [ + 60, + 60, + 66 + ], + [ + 96, + 104, + 96 + ], + [ + 180, + 180, + 172 + ], + [ + 48, + 40, + 36 + ], + [ + 200, + 168, + 136 + ], + [ + 70, + 80, + 72 + ], + [ + 20, + 20, + 22 + ] + ], + "frames": 2, + "facings": { + "down": [ + [ + "0000000000000000", + "00ca004290000000", + "00098222f0000000", + "0000449994000000", + "000b4e9f92000000", + "0000024c24000000", + "0000cfddf2000000", + "000fd2d4ef700000", + "000efe551ef00000", + "0004cef12e2a0000", + "00000fcffe228000", + "00000e8a2c22cc00", + "0000beff1b082900", + "00000f12f0000000", + "00000ccff0000000", + "00000b8000000000" + ], + [ + "0000000800000000", + "00ff002290000000", + "000f5f92f0000000", + "0000792292000000", + "000b292e99000000", + "0000094c9f000000", + "0000efddf2000000", + "0009ded8ecf00000", + "0001ce5e1e200000", + "0002f1f12e2a0000", + "00000fcffe22a000", + "00000eaa2ccccc00", + "00000e7f200b9500", + "0000f214f0000000", + "000000cff0000000", + "000008f000000000" + ] + ], + "up": [ + [ + "0000000f00000000", + "0000002999007000", + "000009227900ce00", + "00000afe44fe0000", + "00000099ffff0000", + "00000ffffcce0000", + "0000f72f29e00000", + "00000c422a170000", + "0000042251fc0000", + "0000c22ac9ae5000", + "00022251e1f10000", + "0009259ec1200000", + "00c4e7fe42000000", + "000000fcf7000000", + "000000fc0f000000", + "0000000000000000" + ], + [ + "0000000f00000000", + "0000009924007000", + "00000f4e2200e000", + "00000ff927fe0000", + "000000ecffff0000", + "00000ffff2290000", + "0000ff9424e00000", + "00000742c5820000", + "0000042291fe0000", + "0000422e1fae0000", + "000222a11e0b0000", + "00022a22eef00000", + "00f4a02e11100000", + "000000ffff000000", + "000000001c000000", + "0000000000000000" + ] + ], + "left": [ + [ + "0000000999f00000", + "0000229942900000", + "000f2922f4900000", + "000ff494f97f0000", + "0000c027f800f000", + "00000ffff2000000", + "0000082f24f00000", + "00000eee24800000", + "0000021e14200000", + "00000cfe1a200000", + "0000ef2e25240000", + "00002eeef4c80000", + "0000ff2e12f80000", + "0000f422f8020000", + "0000bf0fcf000000", + "0000000bb0000000" + ], + [ + "0000000ff2900000", + "0000249272200000", + "00022529479f0000", + "0009f227797fa000", + "0000c0ff78040000", + "00000ff712000000", + "000008ccf4f00000", + "000002eee4800000", + "00000211ef200000", + "0000077eef2a0000", + "0000fffe1f2e0000", + "00001fef22ca0000", + "0000f21e22fa0000", + "000074ff2f090000", + "0000bf00c4000000", + "00000000b0000000" + ] + ], + "right": [ + [ + "0000044c99990000", + "00002ffc9499f000", + "000f44e42248f000", + "000045cff9fc0000", + "0000f2ff22370000", + "00000ec7d5800000", + "00000a4d8ee00000", + "000009ff1e400000", + "00000c24f2f00000", + "000042114f1f0000", + "0000b4ffeeaf0000", + "00005f11e2fff000", + "000f20221e740000", + "000fc0f1f04c2000", + "000f00cf00fc4000", + "0000000500000000" + ], + [ + "0000094c29290000", + "000044fc92e2f000", + "000022c4429cf000", + "00074bcf79f70000", + "0000fbff2f300000", + "000007273ec00000", + "00000c7ddec00000", + "000008741ec00000", + "0000022cf2f00000", + "0000441121f80000", + "00008402eecf0000", + "0000c2c2e2cf0000", + "0008292cee4f0000", + "0008a0f4ffc1f000", + "000000fca0f00000", + "0000000000000000" + ] + ] + } + }, + "lady": { + "palette": [ + [ + 0, + 0, + 0 + ], + [ + 244, + 246, + 248 + ], + [ + 212, + 218, + 226 + ], + [ + 232, + 204, + 176 + ], + [ + 28, + 28, + 32 + ], + [ + 190, + 196, + 206 + ], + [ + 252, + 252, + 252 + ], + [ + 24, + 24, + 28 + ], + [ + 170, + 178, + 190 + ], + [ + 140, + 148, + 160 + ], + [ + 100, + 106, + 118 + ], + [ + 216, + 190, + 160 + ], + [ + 80, + 84, + 94 + ], + [ + 236, + 238, + 244 + ], + [ + 120, + 126, + 138 + ], + [ + 20, + 20, + 24 + ] + ], + "frames": 1, + "facings": { + "down": [ + [ + "0000000440000000", + "0000007acc000000", + "0000004cc4000000", + "00000fc444c00000", + "000000c30f400000", + "00000ffb3b700000", + "00000fe0cfcf0000", + "00000c000a0c0000", + "00000e0e0f080000", + "00000000a0004000", + "0000c3c800800000", + "0000880000c0c000", + "000008000e000000", + "0000800000a90000", + "0000099a09900000", + "00000000a0000000" + ] + ], + "up": [ + [ + "00000000f0000000", + "000000fccc800000", + "0000004ca4f00000", + "0000000cc4400000", + "00000044fcf00000", + "000000c94cf00000", + "00000f40cc900000", + "00000c44ccac0000", + "0000000c44c00000", + "0000a040c4e00000", + "00000944f8008000", + "00000c0059000000", + "0000080000c80000", + "00008000000a0000", + "00000a0000c00000", + "00000004a0000000" + ] + ], + "left": [ + [ + "00000ff000000000", + "00084ccc00000000", + "000c400cf0000000", + "0004bf04f0000000", + "00000a3790000000", + "00004cfcf0000000", + "0000e704c4090000", + "000088044c0a0000", + "0000c90044000000", + "000093000ca00000", + "00000a0000000000", + "000400a000800000", + "0000000890080000", + "0004000000000000", + "00000ca09e000000", + "0000080800000000" + ] + ], + "right": [ + [ + "0000000099000000", + "000000fccc400000", + "0000004c0ccf0000", + "0000007f0cbf0000", + "000009f43b300000", + "0000094cee700000", + "0000eac40ea00000", + "0000044f0c800000", + "000ac4c004000000", + "0000000003000000", + "000000000a000000", + "0000e000e08a0000", + "000008000000c000", + "0000c00008897000", + "000000e0099e0000", + "0000000000000000" + ] + ] + } + }, + "condor": { + "palette": [ + [ + 0, + 0, + 0 + ], + [ + 54, + 42, + 34 + ], + [ + 36, + 28, + 24 + ], + [ + 216, + 160, + 140 + ], + [ + 240, + 214, + 120 + ], + [ + 28, + 22, + 20 + ], + [ + 90, + 72, + 58 + ], + [ + 24, + 20, + 18 + ], + [ + 180, + 130, + 110 + ], + [ + 130, + 100, + 80 + ], + [ + 70, + 56, + 44 + ], + [ + 244, + 238, + 220 + ], + [ + 46, + 36, + 30 + ], + [ + 200, + 180, + 100 + ], + [ + 110, + 88, + 70 + ], + [ + 18, + 16, + 14 + ] + ], + "frames": 1, + "facings": { + "down": [ + [ + "0000000110000000", + "0000036aaa000000", + "0001a6f335acf000", + "00f11a83386aa800", + "00f62ff38ffcaf00", + "006ea598882a6a00", + "01a61ff88ffaa1a0", + "05aa1ce99a2c6670", + "0f1c1a7fff6c1cf0", + "031f166c26ac2130", + "00f2225165c71500", + "002f21aaa1c77f00", + "00ffccffff50fe00", + "0000f61f0a200000", + "00000fa0ffcf0000", + "00000f60f9f50000" + ] + ], + "up": [ + [ + "00000ea000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000330000000", + "000000f11f000000", + "0000e16a66c00000", + "00006166612a0000", + "000f517ca1aa9000", + "0002626a1a7af000", + "000225aa61262000", + "000112a6c6222000", + "000f2c7fcf5c0000", + "0000f7cafcf50000", + "00002f26fff50000", + "0000005cf7300000" + ] + ], + "left": [ + [ + "000006e100000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "0000000000000000", + "00000a661f000000", + "0003c26667500000", + "00533f6e62ca0000", + "0039eaaa6af61000", + "086e7caee165a000", + "0000256a1a211f00", + "0000072aacf22f00", + "000000fa62ffcf00", + "0000006ffaf1c000", + "000000f1fff5f000", + "00008a8000f10000" + ] + ], + "right": [ + [ + "0000000000000000", + "00000002af000000", + "0000012aa6c00000", + "00001661a1aff000", + "000f262a66133800", + "0001a61a2ac993f0", + "00f1a116ec7fe830", + "017f2f66665f0098", + "02ca2f1111200000", + "aa5cf5ca6f000000", + "0cffafaaff000000", + "00acfc5510000000", + "000ea000eff00000", + "00009009e9ee0000", + "0000000300000000", + "0000000000000000" + ] + ] + } + }, + "general": { + "palette": [ + [ + 0, + 0, + 0 + ], + [ + 138, + 100, + 62 + ], + [ + 100, + 72, + 46 + ], + [ + 226, + 190, + 158 + ], + [ + 52, + 40, + 30 + ], + [ + 88, + 62, + 40 + ], + [ + 240, + 236, + 226 + ], + [ + 26, + 22, + 18 + ], + [ + 166, + 128, + 84 + ], + [ + 120, + 90, + 58 + ], + [ + 70, + 52, + 36 + ], + [ + 200, + 168, + 128 + ], + [ + 44, + 34, + 26 + ], + [ + 180, + 150, + 110 + ], + [ + 108, + 82, + 54 + ], + [ + 20, + 16, + 12 + ] + ], + "frames": 1, + "facings": { + "down": [ + [ + "00000000004c0000", + "00000000222e5000", + "00000000e4224400", + "0000000043bbbc00", + "000000004abd8b00", + "000000014bbb8c00", + "000000a1aa37a91d", + "00000029445a522e", + "000000a722422774", + "00000724a22a27d8", + "00000f854444c452", + "00000f17c72777c5", + "000000b755a55434", + "0000000d522aa400", + "0000000daf5c7000", + "00000000c5ccc000" + ] + ], + "up": [ + [ + "0000000000980000", + "00000000a25e2000", + "000000005eee2c00", + "00000000aeeea700", + "000000005aaacd00", + "00000000ac444a00", + "000000f198884290", + "0000005e2225c22c", + "000000797222a219", + "00000c2442525c28", + "00000cbfccca7cd1", + "00000757caa47ca5", + "0000007faa455a8c", + "0000000faac55a00", + "0000000047a57f00", + "00000000aa7c7f00" + ] + ], + "left": [ + [ + "0000000aaa000000", + "000000eee55c0000", + "0000000454e2d000", + "0000000bbaa2d000", + "0000000f349ad000", + "0000004d35c7c000", + "0000000ba22ec000", + "0000000c2e252000", + "000000022f9f4000", + "0000000554527000", + "0000000a5ab3a000", + "000000074412a000", + "0000000a74bc5000", + "0000000477a55700", + "00000055af077500", + "000000d880077000" + ] + ], + "right": [ + [ + "00000000c2ee0000", + "0000000c59aa2000", + "000000055eb32000", + "00000004accb2000", + "0000000c4b363000", + "0000000c9ea24000", + "0000007e1ec54000", + "000000aa1ac20000", + "000000aaa4524000", + "00000074a74a0000", + "0000000db24ac000", + "0000004f14a4c000", + "0000005ab8ca0000", + "000000af7044c000", + "0000072100ca5500", + "000000a400000000" + ] + ] + } + }, + "monk": { + "palette": [ + [ + 0, + 0, + 0 + ], + [ + 172, + 60, + 42 + ], + [ + 122, + 40, + 30 + ], + [ + 222, + 178, + 140 + ], + [ + 212, + 168, + 62 + ], + [ + 240, + 208, + 100 + ], + [ + 244, + 238, + 226 + ], + [ + 30, + 22, + 18 + ], + [ + 196, + 120, + 60 + ], + [ + 150, + 90, + 48 + ], + [ + 90, + 46, + 34 + ], + [ + 252, + 232, + 150 + ], + [ + 60, + 30, + 24 + ], + [ + 230, + 196, + 170 + ], + [ + 140, + 70, + 50 + ], + [ + 22, + 16, + 14 + ] + ], + "frames": 1, + "facings": { + "down": [ + [ + "0000003300000000", + "00000848c0000000", + "00000888c0000000", + "00000acfc0000000", + "0000ae8800000000", + "00007a9ca0000000", + "0000888821000000", + "0000898411000000", + "000ace81c2700000", + "0008a948a2800000", + "00083ac2a7ad0000", + "000fc1c11ca50000", + "0000248a1ca9c000", + "000078f22793f000", + "0000097780980000", + "00000cf490009000" + ] + ], + "up": [ + [ + "0000000033000000", + "0000000883800000", + "0000000a88800000", + "0000000e88e00000", + "00000008987a0000", + "000000a188880000", + "0000001288880000", + "000000122aa80000", + "0000071c2c87e000", + "0000082a998a8000", + "000007a212978000", + "000087a11287c000", + "000e0fc121f20000", + "000f0cc2249f0000", + "0000970aec900000", + "0000000a9ca00000" + ] + ], + "left": [ + [ + "0000000aa0000000", + "0000008388000000", + "000000a849000000", + "00000088ce000000", + "000000a890000000", + "00000008ea000000", + "0000000a21000000", + "0000009212000000", + "0000000a12700000", + "0000008447200000", + "00005549c7200000", + "0000948c721d0000", + "000a49c421180000", + "0000948c72480000", + "0000d828333af000", + "0000003300030000" + ] + ], + "right": [ + [ + "0000003880000000", + "0000008888000000", + "000000e488000000", + "000000c548000000", + "000000a49f000000", + "0000009890000000", + "0000009880000000", + "00000c749a000000", + "00000ac870000000", + "000002ae8a000000", + "0000012c8f8a0000", + "0000a12cc4850000", + "00004112ac94a000", + "0000498cc9440000", + "000ac000c49a0000", + "0000000000000000" + ] + ] + } + } +}; diff --git a/aot/demo-shendiao/assets.ts b/aot/demo-shendiao/assets.ts new file mode 100644 index 0000000..ee46cf7 --- /dev/null +++ b/aot/demo-shendiao/assets.ts @@ -0,0 +1,25 @@ +// aot/demo-shendiao/assets.ts — tileset + sprite declarations for the 神雕 +// fan demo, from the imagegen-derived (or placeholder) data. +import { defineSprite, defineTileset, type Direction } from "@pocketjs/aot"; +import { SPRITES, WUXIA_PALETTE, WUXIA_TILES } from "./assets.generated.ts"; + +export const wuxia = defineTileset("wuxia", { + palette: WUXIA_PALETTE, + tiles: WUXIA_TILES, +}); + +function spriteOf(key: keyof typeof SPRITES, name: string) { + const s = SPRITES[key]; + return defineSprite(name, { + size: [16, 16], + palette: s.palette, + facings: s.facings as Record, + }); +} + +// Player MUST be declared first (the runtimes render sprite id 0 as the player). +export const yangGuo = spriteOf("hero", "yang_guo"); +export const xiaoLongNv = spriteOf("lady", "xiao_long_nv"); +export const condor = spriteOf("condor", "condor"); +export const guoJing = spriteOf("general", "guo_jing"); +export const jinlun = spriteOf("monk", "jinlun"); diff --git a/aot/demo-shendiao/game.tsx b/aot/demo-shendiao/game.tsx new file mode 100644 index 0000000..7935136 --- /dev/null +++ b/aot/demo-shendiao/game.tsx @@ -0,0 +1,533 @@ +/** @jsxImportSource @pocketjs/aot */ +// aot/demo-shendiao/game.tsx — 《神雕旧事》: a fan-made (同人) mini-RPG in +// three segments, authored in the @pocketjs/aot TS DSL and compiled to +// GBA / Game Boy / NES. All dialogue is original text written for this demo +// (plot-inspired; nothing is quoted from the novel). The only quoted couplet +// is 元好问's public-domain 《摸鱼儿·雁丘词》 line. +// +// Title map auto-opens a segment menu (map onEnter); each segment ends by +// warping back to the title. Design doc: scratchpad/shendiao-design.md. +import { + addVar, + ascii, + choose, + defineGame, + defineMap, + giveItem, + hasFlag, + lockPlayer, + releasePlayer, + say, + script, + setFlag, + setVar, + tile, + Trigger, + varEq, + varGe, + varGt, + varLe, + varLt, + wait, + warpTo, +} from "@pocketjs/aot"; +import { condor, guoJing, jinlun, wuxia, xiaoLongNv, yangGuo } from "./assets.ts"; + +/* eslint-disable @typescript-eslint/no-unused-vars */ +void yangGuo; // player = sprite id 0 (declared first in assets.ts) + +// --------------------------------------------------------------------------- +// Title +// --------------------------------------------------------------------------- +const TitleMenu = script(function* () { + yield lockPlayer(); + if (yield hasFlag("s1_done")) + if (yield hasFlag("s2_done")) + if (yield hasFlag("s3_done")) + if (!(yield hasFlag("epilogue"))) { + yield say("三段旧事,到此为止。"); + yield say("问世间,情是何物?"); + yield say("直教生死相许。"); + yield say("少年,江湖是你的了。"); + yield setFlag("epilogue"); + } + yield say("神雕旧事,三段可看。"); + const c = yield choose(["剑冢神雕", "断肠之约", "襄阳大战", "问世间情"] as const); + switch (c) { + case 0: + yield warpTo("valley:spawn"); + yield say("断臂之痛,深谷之中。"); + break; + case 1: + yield warpTo("cliff:spawn"); + yield say("十六年后,断肠崖前。"); + yield say("杨过:龙儿,我来了。"); + break; + case 2: + yield warpTo("xiangyang:spawn"); + yield say("蒙古大军,围困襄阳。"); + yield say("高台烈火,郭襄在上。"); + yield say("杨过:襄儿,我来了!"); + break; + case 3: + yield say("问世间,情是何物?"); + yield say("直教生死相许。"); + break; + } + yield releasePlayer(); +}); + +const TitleCondor = script(function* () { + yield say("雕:……!"); +}); + +// --------------------------------------------------------------------------- +// Segment 1 — 剑冢神雕 +// --------------------------------------------------------------------------- +const CondorTalk = script(function* () { + yield lockPlayer(); + if (!(yield hasFlag("s1_gall"))) { + yield say("雕:……!"); + yield say("神雕飞来,放下蛇胆。"); + yield giveItem("蛇胆"); + yield say("杨过:多谢雕兄!"); + yield say("雕兄看了看石门。"); + yield setFlag("s1_gall"); + } else if (!(yield hasFlag("s1_sword"))) { + yield say("雕兄看了看石门。"); + } else if (!(yield hasFlag("s1_done"))) { + yield say("雕:……!"); + yield say("神雕跃入山洪之中。"); + yield say("杨过:要我在洪水中练剑?"); + yield setVar("train", 0); + while (yield varLt("train", 3)) { + const c = yield choose(["挥剑", "歇息"] as const); + switch (c) { + case 0: + yield addVar("train", 1); + if (yield varEq("train", 1)) yield say("水势如山,剑要脱手。"); + if (yield varEq("train", 2)) yield say("双足生根,剑势渐定。"); + if (yield varEq("train", 3)) yield say("一剑挥出,山洪为之分开!"); + break; + case 1: + yield say("杨过:歇一歇。"); + yield wait(60); + break; + } + } + yield say("杨过:剑重如山,心定如铁。"); + yield say("从此江湖,有一神雕侠。"); + yield setFlag("s1_done"); + yield say("剑冢神雕,到此为止。"); + yield warpTo("title:spawn"); + } else { + yield say("雕:……!"); + } + yield releasePlayer(); +}); + +const ValleyDoor = script(function* () { + yield lockPlayer(); + yield say("石门之后,正是剑冢。"); + yield warpTo("tomb:spawn"); + yield releasePlayer(); +}); + +const TombDoor = script(function* () { + yield lockPlayer(); + yield warpTo("valley:door_front"); + yield releasePlayer(); +}); + +const MoundHeavy = script(function* () { + yield lockPlayer(); + if (yield hasFlag("s1_sword")) { + yield say("石刻:重剑。"); + } else { + yield say("黑铁大剑,重不可当。"); + const c = yield choose(["拔剑", "再看"] as const); + switch (c) { + case 0: + yield say("杨过运力,重剑离石!"); + yield giveItem("玄铁重剑"); + yield say("杨过:好剑,好重的剑!"); + yield setFlag("s1_sword"); + break; + case 1: + break; + } + } + yield releasePlayer(); +}); + +// --------------------------------------------------------------------------- +// Segment 2 — 断肠之约 +// --------------------------------------------------------------------------- +const CliffEdge = script(function* () { + yield lockPlayer(); + yield say("崖下深谷,深不见底。"); + yield say("日出日落,无人前来。"); + yield setVar("edge", 0); + while (yield varEq("edge", 0)) { + const c = yield choose(["再等", "大喊", "纵身一跃"] as const); + switch (c) { + case 0: + yield say("风过崖前,只有花落。"); + yield wait(90); + break; + case 1: + yield say("杨过:龙儿!龙儿!"); + yield say("空谷回声,声声是空。"); + break; + case 2: + yield say("杨过:问世间,情是何物!"); + yield say("龙儿不来,我何必独活!"); + yield say("纵身一跃,直坠深谷。"); + yield warpTo("pool:spawn"); + yield say("谷底寒潭,白花满谷。"); + yield say("潭边有人,一身白衣。"); + yield setVar("edge", 1); + break; + } + } + yield releasePlayer(); +}); + +const Reunion = script(function* () { + yield lockPlayer(); + if (yield hasFlag("s2_done")) { + yield say("小龙女:过儿。"); + yield releasePlayer(); + return; + } + yield say("小龙女:过儿。"); + yield say("杨过:龙儿!真的是你!"); + yield say("小龙女:我等了你十六年。"); + yield say("小龙女:寒潭之下,"); + yield say("我用古墓功法活了下来。"); + yield say("杨过:我以为今生,再见不到你。"); + yield say("小龙女:过儿,你可恨我?"); + const c = yield choose(["不恨", "恨过", "只想你"] as const); + switch (c) { + case 0: + yield say("杨过:不恨。你在,就好。"); + break; + case 1: + yield say("杨过:恨你独去,恨我独活。"); + yield say("小龙女:过儿,是我不好。"); + break; + case 2: + yield say("杨过:十六年,日日想你。"); + break; + } + yield say("小龙女:此后生死,再不分离。"); + yield say("杨过:好。回古墓,回家去。"); + yield setFlag("s2_done"); + yield say("断肠之约,到此为止。"); + yield warpTo("title:spawn"); + yield releasePlayer(); +}); + +// --------------------------------------------------------------------------- +// Segment 3 — 襄阳大战 +// --------------------------------------------------------------------------- +const GuoJingTalk = script(function* () { + yield say("郭靖:过儿,你来了!"); + yield say("襄阳生死,在此一战。"); + yield say("杨过:郭伯伯,看我救襄儿。"); +}); + +const XlnWarn = script(function* () { + yield say("小龙女:小心金轮法王。"); +}); + +// Scripted turn-based boss battle. Balance (design §4.4): 杨过 50 HP vs +// 法王 60 HP; 黯然销魂掌 16 dmg costs 1 气, 玄铁剑法 9 dmg free, 调息 +12 HP +// (cap 50) +1 气; enemy hits 8, every 3rd turn 14. Sword-spam alone loses — +// the 气 economy is the puzzle. Losing soft-resets the segment. +const Battle = script(function* () { + yield lockPlayer(); + yield say("法王:杨过!十六年不见,"); + yield say("今日再分高下!"); + yield say("杨过:放了襄儿,再分高下!"); + yield say("重掌要气,调息回气。"); + yield setVar("yg_hp", 50); + yield setVar("fw_hp", 60); + yield setVar("qi", 1); + yield setVar("turn", 0); + yield setVar("low_told", 0); + + while (yield varGt("fw_hp", 0)) { + const c = yield choose(["黯然销魂掌", "玄铁剑法", "运气调息"] as const); + switch (c) { + case 0: + if (yield varGe("qi", 1)) { + yield addVar("qi", -1); + yield addVar("fw_hp", -16); + yield say("黯然销魂,一掌击出!"); + yield say("法王中掌,连退三步!"); + } else { + yield say("内力不足!"); + } + break; + case 1: + yield addVar("fw_hp", -9); + yield say("重剑一挥,势如山洪!"); + break; + case 2: + yield addVar("yg_hp", 12); + if (yield varGt("yg_hp", 50)) yield setVar("yg_hp", 50); + yield addVar("qi", 1); + yield say("杨过调息,气力渐回。"); + break; + } + if (yield varLe("fw_hp", 0)) break; + + if (yield varLe("fw_hp", 20)) + if (yield varEq("low_told", 0)) { + yield say("法王气息渐乱!"); + yield setVar("low_told", 1); + } + + yield addVar("turn", 1); + if (yield varGe("turn", 3)) { + yield setVar("turn", 0); + yield say("法王:龙象神功!"); + yield say("力大如山,杨过连退三步!"); + yield addVar("yg_hp", -14); + } else { + yield say("法王:金轮,去!"); + yield say("金轮飞来,火光四起!"); + yield addVar("yg_hp", -8); + } + if (yield varLe("yg_hp", 0)) { + yield say("杨过力尽,眼前一黑…"); + yield say("神雕飞来,救走杨过。"); + yield say("胜败常事,再来一次!"); + yield warpTo("xiangyang:spawn"); + yield releasePlayer(); + return; + } + } + + yield say("黯然销魂掌,天下无双!"); + yield say("法王:好掌法……我败了。"); + yield say("法王坠地,高台火起!"); + yield say("杨过飞身上台,救下郭襄。"); + yield say("郭襄:我就知道,大哥哥会来!"); + yield say("军前一人,正是大汗蒙哥。"); + yield say("杨过飞起一石,正中大汗!"); + yield say("大汗坠马,蒙古退兵!"); + yield say("郭靖:为国为民,才是真大侠。"); + yield say("杨过:有郭伯伯在,襄阳不亡。"); + yield setFlag("s3_done"); + yield say("襄阳大战,到此为止。"); + yield warpTo("title:spawn"); + yield releasePlayer(); +}); + +// --------------------------------------------------------------------------- +// Maps +// --------------------------------------------------------------------------- +const LEGEND = { + ".": tile("grass"), + ":": tile("path"), + s: tile("gravel"), + "*": tile("flower"), + "=": tile("bridge"), + "^": tile("stairs"), + _: tile("cave_floor"), + P: tile("platform"), + O: tile("gate"), + "#": tile("cliff"), + X: tile("chasm"), + "~": tile("water"), + "%": tile("rapids"), + T: tile("pine"), + W: tile("tomb_wall"), + D: tile("stone_door"), + "!": tile("sword_mound"), + M: tile("stele"), + C: tile("city_wall"), + b: tile("banner_song"), + m: tile("banner_mongol"), + t: tile("torch"), + f: tile("fire"), + A: tile("tent"), +} as const; + +const pick = (...keys: K[]): Pick => { + const out = {} as Pick; + for (const k of keys) out[k] = LEGEND[k]; + return out; +}; + +export const Title = defineMap("title") + .tileset(wuxia) + .layer( + ascii` + ################ + #T.T........T.T# + #..*...M....*..# + #......:.......# + #..T...:...T...# + #......:.....~~# + #T.....:....~~~# + #..*...:.....~~# + #..T...:...T...# + #......:.......# + #T.T...:....T.T# + ################ + `.legend(pick("#", "T", ".", "*", ":", "~", "M")), + ) + .spawn("spawn").at(7, 10).facing("up") + .npc("condor").sprite(condor).at(9, 2).facing("down").talk(TitleCondor) + .entities() + .onEnter(TitleMenu) + .done(); + +export const Valley = defineMap("valley") + .tileset(wuxia) + .layer( + ascii` + ######%%########D####### + #....s%%s.......:......# + #.T..s%%s..T....:..T...# + #....s%%s.......:......# + #..T.s%%s....*..:....T.# + #....s%%s.......:......# + #.T..s==s....:::::....T# + #....s%%s....:.........# + #.T..s%%s..*.:...T.....# + #....s%%s....:....*....# + #..T.s%%s....:..T......# + #....s%%s....:.....T...# + #.T..s%%s....:.........# + ######################## + `.legend(pick("#", ".", "s", "%", "=", "D", ":", "T", "*")), + ) + .spawn("spawn").at(13, 12).facing("up") + .entrance("door_front").at(16, 1).facing("down") + .npc("condor").sprite(condor).at(14, 10).facing("left").talk(CondorTalk) + .sign("山洪之声,水花四起。").at(8, 4) + .entities() + .done(); + +export const Tomb = defineMap("tomb") + .tileset(wuxia) + .layer( + ascii` + WWWWWWWWWWWWWWWWWWWW + W__________________W + W__!___!____!___!__W + W__________________W + W__________________W + W__________________W + W__________________W + W__________________W + W_____M____________W + WWWWWWWWWDWWWWWWWWWW + `.legend(pick("W", "_", "!", "M", "D")), + ) + .spawn("spawn").at(9, 8).facing("up") + .sign("石刻:利剑。少年以之,败尽强手。").at(3, 2) + .sign("石刻:软剑。剑不在此,弃之深谷。").at(7, 2) + .sign("石刻:木剑。大道至此,草木皆剑。").at(16, 2) + .sign("剑冢。天下之剑,尽在此地。").at(6, 8) + .entities( + <> + + + , + ) + .done(); + +export const Cliff = defineMap("cliff") + .tileset(wuxia) + .layer( + ascii` + #################### + #T.T....:.....*..T.# + #..*....:..T.......# + #T......:......*..T# + #...T...:...T......# + #*......:.......*..# + #...*...:....*.....# + #T......:......T...# + #....*.M:*.........# + #..*....:..*....*..# + #XXXXXXXXXXXXXXXXXX# + #XXXXXXXXXXXXXXXXXX# + #XXXXXXXXXXXXXXXXXX# + #################### + `.legend(pick("#", "T", ".", "*", ":", "M", "X")), + ) + .spawn("spawn").at(8, 1).facing("down") + .sign("石上旧字:「十六年后,在此重逢。」").at(7, 8) + .sign("崖前白花,开了,落了。").at(13, 6) + .entities() + .done(); + +export const Pool = defineMap("pool") + .tileset(wuxia) + .layer( + ascii` + #################### + #..*....*....*...T.# + #.*......s.....*...# + #....sss~~sss...*..# + #..*.s~~~~~~s......# + #....s~~~~~~s..*...# + #.*..s~~~~~~s......# + #....sss~~sss..T...# + #..*....s.s..*.....# + #.T...*.....*....*.# + #........*.......T.# + #################### + `.legend(pick("#", ".", "*", "s", "~", "T")), + ) + .spawn("spawn").at(9, 2).facing("down") + .npc("xiao_long_nv").sprite(xiaoLongNv).at(13, 5).facing("left").talk(Reunion) + .done(); + +export const Xiangyang = defineMap("xiangyang") + .tileset(wuxia) + .layer( + ascii` + CCCCCCCCCCCbOObCCCCCCCCCCC + CCCCCCCCCCCtOOtCCCCCCCCCCC + #...........::...........# + #...........::...........# + #...........::...........# + #...........::...t....t..# + #...........::....PPPP...# + #...........::...^PPPP...# + #...........::....PPPP...# + #...........::...ffffff..# + #...........::...........# + #...........::...........# + #..m........::......m....# + #.AAA.......::....AAA....# + #.AAA...m...::....AAA..m.# + #.AAA.......::....AAA....# + #...........::...........# + ########################## + `.legend(pick("C", "b", "O", "t", "#", ".", ":", "m", "A", "P", "^", "f")), + ) + .spawn("spawn").at(12, 3).facing("down") + .npc("guo_jing").sprite(guoJing).at(11, 3).facing("right").talk(GuoJingTalk) + .npc("xiao_long_nv").sprite(xiaoLongNv).at(14, 3).facing("left").talk(XlnWarn) + .npc("jinlun").sprite(jinlun).at(15, 7).facing("left").talk(Battle) + .sign("郭襄:大哥哥,不要救我!先破蒙古大军!小心法王!").at(18, 7) + .done(); + +export default defineGame({ + title: "SHENDIAO JIUSHI", + start: "title:spawn", + textMode: "cjk16", + maps: [Title, Valley, Tomb, Cliff, Pool, Xiangyang], + sprites: ["yang_guo", "xiao_long_nv", "condor", "guo_jing", "jinlun"], + items: ["蛇胆", "玄铁重剑"], + flags: ["s1_gall", "s1_sword", "s1_done", "s2_done", "s3_done", "epilogue"], + vars: ["train", "edge", "yg_hp", "fw_hp", "qi", "turn", "low_told"], +}); diff --git a/aot/demo-shendiao/imagegen/build-assets.ts b/aot/demo-shendiao/imagegen/build-assets.ts new file mode 100644 index 0000000..b0aca86 --- /dev/null +++ b/aot/demo-shendiao/imagegen/build-assets.ts @@ -0,0 +1,478 @@ +#!/usr/bin/env bun +// Convert the imagegen source sheets into DSL asset data for the 神雕 demo. +// +// bun aot/demo-shendiao/imagegen/build-assets.ts +// +// Reads the six *-source.png sheets in this directory (tileset + 5 character +// sheets) and writes ../assets.generated.ts. While a sheet is missing, the +// corresponding assets fall back to deterministic flat-color placeholders so +// the game stays buildable end-to-end. + +import { existsSync } from "node:fs"; +import { basename, dirname, join } from "node:path"; +import { decodePng, type DecodedImage } from "../../../compiler/pak.ts"; + +type RGB = [number, number, number]; +type Rect = { x: number; y: number; w: number; h: number }; + +const HERE = dirname(new URL(import.meta.url).pathname); +const OUT = join(dirname(HERE), "assets.generated.ts"); + +// --------------------------------------------------------------------------- +// Palettes (16 RGB each). Index 0 = backdrop (tiles) / transparent (sprites). +// Cold grey-green wuxia mood; Xiangyang warm accents live in the same bank. +// --------------------------------------------------------------------------- +const WUXIA_PALETTE: RGB[] = [ + [16, 18, 22], // 0 backdrop / abyss black + [96, 144, 88], // 1 grass green + [56, 96, 64], // 2 dark green (pine) + [140, 144, 148], // 3 rock grey + [88, 92, 100], // 4 dark grey (wall shade) + [52, 84, 132], // 5 deep water blue + [236, 240, 240], // 6 white (foam / blossom / snow) + [172, 176, 176], // 7 pale stone + [148, 108, 64], // 8 wood brown + [92, 64, 40], // 9 dark wood + [196, 60, 48], // 10 banner red + [232, 148, 48], // 11 flame orange + [248, 220, 96], // 12 flame yellow + [120, 116, 108], // 13 felt grey + [32, 40, 52], // 14 near-black blue (mist/chasm streak) + [24, 26, 30], // 15 outline +]; + +const SPRITE_PALETTES: Record = { + hero: [ + [0, 0, 0], // transparent + [58, 70, 60], // dark grey-green robe + [40, 48, 44], // robe shade + [232, 200, 168], // skin + [30, 30, 34], // hair / iron sword + [80, 84, 92], // sword edge highlight + [244, 244, 236], // eye/white + [24, 24, 26], + [140, 120, 96], + [60, 60, 66], + [96, 104, 96], + [180, 180, 172], + [48, 40, 36], + [200, 168, 136], + [70, 80, 72], + [20, 20, 22], + ], + lady: [ + [0, 0, 0], + [244, 246, 248], // white robe + [212, 218, 226], // robe shade + [232, 204, 176], // skin + [28, 28, 32], // black hair + [190, 196, 206], + [252, 252, 252], + [24, 24, 28], + [170, 178, 190], + [140, 148, 160], + [100, 106, 118], + [216, 190, 160], + [80, 84, 94], + [236, 238, 244], + [120, 126, 138], + [20, 20, 24], + ], + condor: [ + [0, 0, 0], + [54, 42, 34], // near-black brown body + [36, 28, 24], // body shade + [216, 160, 140], // bald pinkish head + [240, 214, 120], // beak horn + [28, 22, 20], + [90, 72, 58], // feather highlight + [24, 20, 18], + [180, 130, 110], + [130, 100, 80], + [70, 56, 44], + [244, 238, 220], + [46, 36, 30], + [200, 180, 100], + [110, 88, 70], + [18, 16, 14], + ], + general: [ + [0, 0, 0], + [138, 100, 62], // brown robe + [100, 72, 46], // robe shade + [226, 190, 158], // skin + [52, 40, 30], // beard/hair + [88, 62, 40], // leather + [240, 236, 226], + [26, 22, 18], + [166, 128, 84], + [120, 90, 58], + [70, 52, 36], + [200, 168, 128], + [44, 34, 26], + [180, 150, 110], + [108, 82, 54], + [20, 16, 12], + ], + monk: [ + [0, 0, 0], + [172, 60, 42], // red robe + [122, 40, 30], // robe shade + [222, 178, 140], // skin (bare shoulder, shaved head) + [212, 168, 62], // golden wheel / earrings + [240, 208, 100], // gold highlight + [244, 238, 226], + [30, 22, 18], + [196, 120, 60], // ochre layer + [150, 90, 48], + [90, 46, 34], + [252, 232, 150], + [60, 30, 24], + [230, 196, 170], + [140, 70, 50], + [22, 16, 14], + ], +}; + +// --------------------------------------------------------------------------- +// Tile specs: name, solid, placeholder recipe, and (once the sheet exists) +// crop rects into tileset-source.png. +// --------------------------------------------------------------------------- +interface TileSpec { + name: string; + solid?: boolean; + /** placeholder: [fill, accent] palette indices */ + ph: [number, number]; + /** sheet cell (col, row); rows have 6,6,6,7 columns */ + cell: [number, number]; + /** palette index composited under near-white sheet background, if any */ + bgFill?: number; +} + +// tileset-source.png layout: 4 rows of cells (6 + 6 + 6 + 7); decor cells sit +// on a near-white background that gets composited over bgFill. +const TILE_SPECS: TileSpec[] = [ + { name: "grass", ph: [1, 2], cell: [0, 0] }, + { name: "path", ph: [8, 9], cell: [2, 0] }, + { name: "gravel", ph: [7, 3], cell: [3, 0] }, + { name: "flower", ph: [1, 6], cell: [4, 0] }, + { name: "bridge", ph: [8, 9], cell: [5, 0] }, + { name: "stairs", ph: [9, 8], cell: [0, 1] }, + { name: "cave_floor", ph: [4, 14], cell: [1, 1] }, + { name: "platform", ph: [9, 8], cell: [2, 1] }, + { name: "gate", ph: [14, 4], cell: [3, 1] }, + { name: "cliff", solid: true, ph: [3, 4], cell: [4, 1] }, + { name: "chasm", solid: true, ph: [0, 14], cell: [5, 1] }, + { name: "water", solid: true, ph: [5, 14], cell: [0, 2] }, + { name: "rapids", solid: true, ph: [5, 6], cell: [1, 2] }, + { name: "pine", solid: true, ph: [2, 15], cell: [2, 2], bgFill: 1 }, + { name: "tomb_wall", solid: true, ph: [4, 15], cell: [3, 2] }, + { name: "stone_door", solid: true, ph: [7, 4], cell: [4, 2] }, + { name: "sword_mound", solid: true, ph: [3, 15], cell: [5, 2], bgFill: 1 }, + { name: "stele", solid: true, ph: [7, 15], cell: [0, 3], bgFill: 1 }, + { name: "city_wall", solid: true, ph: [4, 3], cell: [1, 3], bgFill: 14 }, + { name: "banner_song", solid: true, ph: [10, 9], cell: [2, 3], bgFill: 1 }, + { name: "banner_mongol", solid: true, ph: [14, 9], cell: [3, 3], bgFill: 1 }, + { name: "torch", solid: true, ph: [11, 12], cell: [4, 3], bgFill: 1 }, + { name: "fire", solid: true, ph: [11, 12], cell: [5, 3], bgFill: 1 }, + { name: "tent", solid: true, ph: [13, 9], cell: [6, 3], bgFill: 1 }, +]; +const TILE_ROW_COLS = [6, 6, 6, 7]; + +interface CharSpec { + key: string; + frames: number; + grid: [number, number]; // cols, rows + cells: Record<"down" | "up" | "left" | "right", [number, number][]>; // per frame +} +// Each imagegen character sheet came back with its own grid; cells were +// mapped by visual inspection. +const SPRITES: CharSpec[] = [ + { + key: "hero", + frames: 2, + grid: [2, 4], + cells: { down: [[0, 0], [1, 0]], up: [[0, 1], [1, 1]], left: [[0, 2], [1, 2]], right: [[0, 3], [1, 3]] }, + }, + { + key: "lady", + frames: 1, + grid: [4, 4], + cells: { down: [[0, 0]], up: [[2, 0]], left: [[0, 1]], right: [[2, 1]] }, + }, + { + key: "condor", + frames: 1, + grid: [4, 5], + cells: { down: [[0, 0]], up: [[0, 1]], left: [[0, 2]], right: [[0, 3]] }, + }, + { + key: "general", + frames: 1, + grid: [4, 4], + cells: { down: [[0, 0]], up: [[0, 1]], left: [[0, 2]], right: [[0, 3]] }, + }, + { + key: "monk", + frames: 1, + grid: [2, 4], + cells: { down: [[0, 0]], up: [[0, 1]], left: [[0, 2]], right: [[0, 3]] }, + }, +]; + +// --------------------------------------------------------------------------- +// Placeholder painters (used until the imagegen sheets land) +// --------------------------------------------------------------------------- +function placeholderTile(spec: TileSpec): string[] { + const [fill, accent] = spec.ph; + const rows: string[] = []; + for (let y = 0; y < 8; y++) { + let row = ""; + for (let x = 0; x < 8; x++) { + const accentHere = (x * 7 + y * 3 + spec.name.length) % 11 === 0 || (spec.solid && (y === 0 || x === 0)); + row += (accentHere ? accent : fill).toString(16); + } + rows.push(row); + } + return rows; +} + +function placeholderFrame(key: string, dir: number, frame: number): string[] { + // A readable 16x16 biped blob: head + body in the sprite's own palette, + // with a direction marker pixel so facing changes are visible. + const body = 1; + const shade = 2; + const head = 3; + const dark = 4; + const grid: number[][] = Array.from({ length: 16 }, () => new Array(16).fill(0)); + for (let y = 2; y < 7; y++) for (let x = 5; x < 11; x++) grid[y][x] = head; + for (let y = 7; y < 14; y++) for (let x = 4; x < 12; x++) grid[y][x] = body; + for (let y = 7; y < 14; y++) grid[y][4 + ((y + frame) % 2)] = shade; + for (let x = 5; x < 11; x++) grid[14][x] = dark; + const marks: Record = { 0: [8, 6], 1: [8, 2], 2: [5, 4], 3: [10, 4] }; + const [mx, my] = marks[dir]; + grid[my][mx] = dark; + if (key === "condor") for (let x = 3; x < 13; x++) grid[8][x] = 1; // wing bar + return grid.map((r) => r.map((v) => v.toString(16)).join("")); +} + +// --------------------------------------------------------------------------- +// Sheet extraction (real art path) +// --------------------------------------------------------------------------- +function px(img: DecodedImage, x: number, y: number): RGB { + const sx = Math.max(0, Math.min(img.width - 1, x)); + const sy = Math.max(0, Math.min(img.height - 1, y)); + const i = (sy * img.width + sx) * 4; + return [img.rgba[i], img.rgba[i + 1], img.rgba[i + 2]]; +} +function dist2(a: RGB, b: RGB): number { + return (a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2; +} +function nearest(rgb: RGB, pal: RGB[], start: number): number { + let best = start; + let bestD = Infinity; + for (let i = start; i < pal.length; i++) { + const d = dist2(rgb, pal[i]); + if (d < bestD) { + bestD = d; + best = i; + } + } + return best; +} +function avgRegion(img: DecodedImage, r: Rect, ox: number, oy: number, ow: number, oh: number): RGB { + const x0 = Math.floor(r.x + (ox / ow) * r.w); + const y0 = Math.floor(r.y + (oy / oh) * r.h); + const x1 = Math.max(x0 + 1, Math.floor(r.x + ((ox + 1) / ow) * r.w)); + const y1 = Math.max(y0 + 1, Math.floor(r.y + ((oy + 1) / oh) * r.h)); + let rr = 0; + let gg = 0; + let bb = 0; + let n = 0; + for (let y = y0; y < y1; y++) + for (let x = x0; x < x1; x++) { + const c = px(img, x, y); + rr += c[0]; + gg += c[1]; + bb += c[2]; + n++; + } + return [Math.round(rr / n), Math.round(gg / n), Math.round(bb / n)]; +} +function neutralBg(rgb: RGB): boolean { + const neutral = Math.abs(rgb[0] - rgb[1]) < 14 && Math.abs(rgb[1] - rgb[2]) < 14; + return neutral && rgb[0] > 185; +} + +function tileFromSheet(img: DecodedImage, rect: Rect, bgFill?: number): string[] { + const rows: string[] = []; + for (let y = 0; y < 8; y++) { + let row = ""; + for (let x = 0; x < 8; x++) { + const rgb = avgRegion(img, rect, x, y, 8, 8); + const idx = bgFill !== undefined && neutralBg(rgb) ? bgFill : nearest(rgb, WUXIA_PALETTE, 1); + row += idx.toString(16); + } + rows.push(row); + } + return rows; +} + +function borderAvg(img: DecodedImage, rect: Rect): RGB { + let r = 0; + let g = 0; + let b = 0; + let n = 0; + const add = (x: number, y: number): void => { + const c = px(img, x, y); + r += c[0]; + g += c[1]; + b += c[2]; + n++; + }; + for (let x = rect.x; x < rect.x + rect.w; x += 2) { + add(x, rect.y); + add(x, rect.y + rect.h - 1); + } + for (let y = rect.y; y < rect.y + rect.h; y += 2) { + add(rect.x, y); + add(rect.x + rect.w - 1, y); + } + return [Math.round(r / n), Math.round(g / n), Math.round(b / n)]; +} + +function spriteFromSheet(img: DecodedImage, rect: Rect, pal: RGB[]): string[] { + const bg = borderAvg(img, rect); + const isBg = (rgb: RGB): boolean => dist2(rgb, bg) < 34 * 34 || neutralBg(rgb); + let minX = rect.x + rect.w; + let minY = rect.y + rect.h; + let maxX = rect.x; + let maxY = rect.y; + for (let y = rect.y; y < rect.y + rect.h; y++) + for (let x = rect.x; x < rect.x + rect.w; x++) + if (!isBg(px(img, x, y))) { + minX = Math.min(minX, x); + minY = Math.min(minY, y); + maxX = Math.max(maxX, x); + maxY = Math.max(maxY, y); + } + const side = Math.ceil(Math.max(maxX - minX + 1, maxY - minY + 1) * 1.06); + const cx = (minX + maxX + 1) / 2; + const cy = (minY + maxY + 1) / 2; + const crop: Rect = { x: Math.round(cx - side / 2), y: Math.round(cy - side / 2), w: side, h: side }; + const rows: string[] = []; + for (let oy = 0; oy < 16; oy++) { + let row = ""; + for (let ox = 0; ox < 16; ox++) { + const sx = Math.floor(crop.x + ((ox + 0.5) / 16) * crop.w); + const sy = Math.floor(crop.y + ((oy + 0.5) / 16) * crop.h); + const rgb = px(img, sx, sy); + row += (isBg(rgb) ? 0 : nearest(rgb, pal, 1)).toString(16); + } + rows.push(row); + } + return rows; +} + +/** Uniform grid crops over a sheet (cols x rows cells with even gutters). */ +function gridRect(img: DecodedImage, cols: number, rows: number, cx: number, cy: number, inset = 0.08): Rect { + const cw = img.width / cols; + const ch = img.height / rows; + const ix = cw * inset; + const iy = ch * inset; + return { x: Math.round(cx * cw + ix), y: Math.round(cy * ch + iy), w: Math.round(cw - 2 * ix), h: Math.round(ch - 2 * iy) }; +} + +// --------------------------------------------------------------------------- +async function main(): Promise { + const DIRS = ["down", "up", "left", "right"] as const; + + // tiles + let tiles: Record = {}; + const tilesheetPath = join(HERE, "tileset-source.png"); + if (existsSync(tilesheetPath)) { + const img = decodePng(await Bun.file(tilesheetPath).bytes()); + // The sheet's outer margins differ from its inner gutters, so a uniform + // grid bleeds gutter white into cell edges. Detect content bands instead: + // contiguous runs of rows/cols whose non-white pixel density is high. + const bands = (len: number, density: (i: number) => number, minLen: number): [number, number][] => { + const out: [number, number][] = []; + let start = -1; + for (let i = 0; i <= len; i++) { + const on = i < len && density(i) > 0.08; + if (on && start < 0) start = i; + if (!on && start >= 0) { + if (i - start >= minLen) out.push([start, i]); + start = -1; + } + } + return out; + }; + const rowDensity = (y: number): number => { + let n = 0; + for (let x = 0; x < img.width; x += 4) if (!neutralBg(px(img, x, y))) n++; + return n / (img.width / 4); + }; + const rowBands = bands(img.height, rowDensity, 60); + const cellRects: Rect[][] = rowBands.map(([y0, y1]) => { + const colDensity = (x: number): number => { + let n = 0; + for (let y = y0; y < y1; y += 4) if (!neutralBg(px(img, x, y))) n++; + return n / ((y1 - y0) / 4); + }; + return bands(img.width, colDensity, 60).map(([x0, x1]) => { + const inw = Math.round((x1 - x0) * 0.04); + const inh = Math.round((y1 - y0) * 0.04); + return { x: x0 + inw, y: y0 + inh, w: x1 - x0 - 2 * inw, h: y1 - y0 - 2 * inh }; + }); + }); + for (const spec of TILE_SPECS) { + const [cx, cy] = spec.cell; + let rect = cellRects[cy]?.[cx]; + if (!rect) { + // fallback: uniform grid + const cols = TILE_ROW_COLS[cy]; + const cw = img.width / cols; + const ch = img.height / TILE_ROW_COLS.length; + rect = { x: Math.round(cx * cw + cw * 0.12), y: Math.round(cy * ch + ch * 0.12), w: Math.round(cw * 0.76), h: Math.round(ch * 0.76) }; + } + tiles[spec.name] = { px: tileFromSheet(img, rect, spec.bgFill), ...(spec.solid ? { solid: true } : {}) }; + } + } else { + for (const spec of TILE_SPECS) { + tiles[spec.name] = { px: placeholderTile(spec), ...(spec.solid ? { solid: true } : {}) }; + } + } + + // sprites + const sprites: Record }> = {}; + for (const spec of SPRITES) { + const { key, frames } = spec; + const pal = SPRITE_PALETTES[key]; + const sheet = join(HERE, `${key}-source.png`); + const facings: Record = {}; + if (existsSync(sheet)) { + const img = decodePng(await Bun.file(sheet).bytes()); + const [gc, gr] = spec.grid; + for (const d of DIRS) { + facings[d] = spec.cells[d].slice(0, frames).map(([cx, cy]) => spriteFromSheet(img, gridRect(img, gc, gr, cx, cy, 0.06), pal)); + } + } else { + DIRS.forEach((d, di) => { + facings[d] = Array.from({ length: frames }, (_, f) => placeholderFrame(key, di, f)); + }); + } + sprites[key] = { palette: pal, frames, facings }; + } + + const body = + `// GENERATED by ${basename(new URL(import.meta.url).pathname)} — do not edit by hand.\n` + + `// Sources: aot/demo-shendiao/imagegen/*-source.png (placeholders where missing).\n\n` + + `export const WUXIA_PALETTE: [number, number, number][] = ${JSON.stringify(WUXIA_PALETTE)};\n\n` + + `export const WUXIA_TILES = ${JSON.stringify(tiles, null, 1)};\n\n` + + `export const SPRITES = ${JSON.stringify(sprites, null, 1)};\n`; + await Bun.write(OUT, body); + console.log(`generated ${OUT}`); +} + +await main(); diff --git a/aot/demo-shendiao/imagegen/condor-source.png b/aot/demo-shendiao/imagegen/condor-source.png new file mode 100644 index 0000000..e073dc7 Binary files /dev/null and b/aot/demo-shendiao/imagegen/condor-source.png differ diff --git a/aot/demo-shendiao/imagegen/general-source.png b/aot/demo-shendiao/imagegen/general-source.png new file mode 100644 index 0000000..9da46da Binary files /dev/null and b/aot/demo-shendiao/imagegen/general-source.png differ diff --git a/aot/demo-shendiao/imagegen/hero-source.png b/aot/demo-shendiao/imagegen/hero-source.png new file mode 100644 index 0000000..ad96e76 Binary files /dev/null and b/aot/demo-shendiao/imagegen/hero-source.png differ diff --git a/aot/demo-shendiao/imagegen/lady-source.png b/aot/demo-shendiao/imagegen/lady-source.png new file mode 100644 index 0000000..866a246 Binary files /dev/null and b/aot/demo-shendiao/imagegen/lady-source.png differ diff --git a/aot/demo-shendiao/imagegen/monk-source.png b/aot/demo-shendiao/imagegen/monk-source.png new file mode 100644 index 0000000..4628f2f Binary files /dev/null and b/aot/demo-shendiao/imagegen/monk-source.png differ diff --git a/aot/demo-shendiao/imagegen/tileset-source.png b/aot/demo-shendiao/imagegen/tileset-source.png new file mode 100644 index 0000000..40b8d59 Binary files /dev/null and b/aot/demo-shendiao/imagegen/tileset-source.png differ diff --git a/aot/dsl/index.ts b/aot/dsl/index.ts index 6622b21..e9cab62 100644 --- a/aot/dsl/index.ts +++ b/aot/dsl/index.ts @@ -20,6 +20,7 @@ import { type PjgbNode, type PlayerSpawnProps, type SignProps, + type TriggerProps, type WarpProps, } from "./jsx-runtime.ts"; import type { @@ -45,6 +46,7 @@ export type { PjgbNode, PlayerSpawnProps, SignProps, + TriggerProps, WarpProps, } from "./jsx-runtime.ts"; @@ -117,6 +119,7 @@ export interface MapDecl { tileset: string; root: PjgbNode; // the element tree size?: [number, number]; + onEnter?: ScriptRef; // script that runs whenever this map loads } export interface GameDecl { title: string; @@ -127,6 +130,12 @@ export interface GameDecl { battles?: string[]; flags?: string[]; vars?: string[]; + /** + * Text renderer: "ascii8" = legacy 8x8 ASCII font (GBA only); "cjk16" = + * 16px lines with on-demand Unifont glyph streaming (any target; forced on + * gb/nes). Default: ascii8 on gba, cjk16 elsewhere. + */ + textMode?: "ascii8" | "cjk16"; } export interface Registry { @@ -148,10 +157,17 @@ const REGISTRY: Registry = { scriptCount: 0, }; -/** Compiler entry point: reset before executing a fresh game module. */ +/** + * Compiler entry point: reset before executing a fresh game module. + * + * Only per-run state (the game decl + the map list) is cleared. Tileset and + * sprite declarations often live in helper modules (e.g. demo/assets.ts) + * whose top-level side effects run ONCE per process — Bun caches them by + * resolved path — so clearing those interners would lose them on the second + * compile in one process (e.g. building several targets). Their define* + * calls are idempotent (keyed by name), so persisting them is safe. + */ export function __resetRegistry(): void { - REGISTRY.tilesets.clear(); - REGISTRY.sprites.clear(); REGISTRY.maps = []; REGISTRY.game = null; REGISTRY.scriptCount = 0; @@ -171,7 +187,7 @@ export const Warp = hostElement("Warp"); export const Sign = hostElement("Sign"); export const PlayerSpawn = hostElement("PlayerSpawn"); export const Entrance = hostElement("Entrance"); -export const Trigger = hostElement("Trigger"); +export const Trigger = hostElement("Trigger"); export const Collision = hostElement("Collision"); // --------------------------------------------------------------------------- @@ -204,7 +220,7 @@ type ExtractTileNames = Extract< }[keyof Legend], string >; -const ENTITY_HOSTS = new Set(["PlayerSpawn", "Entrance", "Npc", "Sign", "Warp"]); +const ENTITY_HOSTS = new Set(["PlayerSpawn", "Entrance", "Npc", "Sign", "Warp", "Trigger"]); function node(host: string, props: Record, children: PjgbNode[] = []): PjgbNode { return { host, props, children }; @@ -444,6 +460,12 @@ export class MapBuilder c.host === "Layer"); const rows = firstLayer?.props.rows as string[] | undefined; @@ -453,6 +475,7 @@ export class MapBuilder>( diff --git a/aot/package.json b/aot/package.json index 599d58b..60aaf19 100644 --- a/aot/package.json +++ b/aot/package.json @@ -16,13 +16,21 @@ }, "scripts": { "assets": "bun demo/imagegen/build-assets.ts", + "assets:shendiao": "bun demo-shendiao/imagegen/build-assets.ts", "build": "bun run assets && bun compiler/cli.ts build demo/game.tsx --out dist/pocket-town.gba", + "build:gb": "bun run assets && bun compiler/cli.ts build demo/game.tsx --target gb --out dist/pocket-town.gb", + "build:nes": "bun run assets && bun compiler/cli.ts build demo/game.tsx --target nes --out dist/pocket-town.nes", + "shendiao": "bun run assets:shendiao && bun compiler/cli.ts build demo-shendiao/game.tsx --out dist/shendiao.gba && bun compiler/cli.ts build demo-shendiao/game.tsx --target gb --out dist/shendiao.gb && bun compiler/cli.ts build demo-shendiao/game.tsx --target nes --out dist/shendiao.nes", "play": "bash play.sh", "gen": "bun spec/gen-c.ts", "handcart": "bun test/handcart.ts", - "test": "bun run assets && bun test/e2e.ts" + "test": "bun run assets && bun test/e2e.ts", + "test:multi": "bun run assets && bun test/e2e-multi.ts gba gb nes", + "test:shendiao": "bun run assets:shendiao && bun test/e2e-shendiao.ts", + "test:all": "bun run test:multi && bun run test:shendiao" }, "devDependencies": { + "jsnes": "^2.1.0", "typescript": "^5" } } diff --git a/aot/runtime/gb/gbrt.c b/aot/runtime/gb/gbrt.c new file mode 100644 index 0000000..abfab0c --- /dev/null +++ b/aot/runtime/gb/gbrt.c @@ -0,0 +1,269 @@ +// aot/runtime/gb/gbrt.c — platform layer: video boot, map load, collision, +// player movement, camera, OAM scene, input, debug block. +#include +#include "gbrt.h" + +PjGame g; +PjActor pj_actors[BUDGET_MAX_ACTORS_PER_MAP]; +PjWarp pj_warps[8]; +u8 pj_script_ram[PJ_SCRIPT_BUF]; + +static const s8 DX[4] = {0, 0, -1, 1}; // down, up, left, right +static const s8 DY[4] = {1, -1, 0, 0}; + +#define PLAYER_SPEED 2 +#define ANIM_RATE 6 + +// --- VRAM addressing (signed BG tile mode: LCDC.4 = 0) ---------------------- +u8 *bg_tile_addr(u8 id) { + if (id < 128) return (u8 *)(0x9000 + (u16)id * 16); + return (u8 *)(0x8800 + (u16)(id - 128) * 16); +} + +// --- boot -------------------------------------------------------------------- +void video_boot(void) { + DISPLAY_OFF; + LCDC_REG = 0x00; // off; BG data signed, BG map 0x9800, WIN map 0x9C00, 8x16 OBJ + BGP_REG = 0xE4; + OBP0_REG = 0xE4; + + SWITCH_ROM(BANK(pj_bg_tiles)); + { + u8 i; + for (i = 0; i < PJ_BG_TILE_COUNT; i++) { + memcpy(bg_tile_addr(i), pj_bg_tiles + (u16)i * 16, 16); + } + } + SWITCH_ROM(BANK(pj_obj_tiles)); + set_sprite_data(0, PJ_OBJ_TILE_COUNT, pj_obj_tiles); + + SPRITES_8x16; + WX_REG = 7; + WY_REG = 144; // window offscreen until a textbox opens +} + +// --- map ---------------------------------------------------------------------- +void map_enter(u8 map_id, u8 tx, u8 ty, u8 dir) { + const PjMapInfo *mi = &pj_maps[map_id]; + u8 lcd_was_on = (LCDC_REG & 0x80) ? 1 : 0; + u8 i; + + if (lcd_was_on) DISPLAY_OFF; + + g.map_id = map_id; + g.map_w = mi->w; + g.map_h = mi->h; + g.map_bank = mi->bank; + g.map_tiles = mi->tiles; + g.map_coll = mi->coll; + g.n_actors = mi->n_actors; + g.n_warps = mi->n_warps > 8 ? 8 : mi->n_warps; + + SWITCH_ROM(mi->bank); + memcpy(pj_actors, mi->actors, sizeof(PjActor) * mi->n_actors); + memcpy(pj_warps, mi->warps, sizeof(PjWarp) * g.n_warps); + for (i = 0; i < g.n_actors; i++) { + g.actor_dir[i] = pj_actors[i].facing; + g.actor_frame[i] = 0; + } + + // Whole BG map: map tiles inside, blank (0) outside. + { + u8 cx, cy; + u8 *dst = (u8 *)0x9800; + const u8 *row = g.map_tiles; + for (cy = 0; cy < 32; cy++) { + if (cy < g.map_h) { + for (cx = 0; cx < 32; cx++) dst[cx] = (cx < g.map_w) ? row[cx] : 0; + row += g.map_w; + } else { + memset(dst, 0, 32); + } + dst += 32; + } + } + + g.px = (s16)tx * 8; + g.py = (s16)ty * 8; + g.dir = dir; + g.moving = 0; + g.anim_frame = 0; + g.anim_timer = 0; + + g.pending_enter = (mi->on_enter == 0xff) ? -1 : (s8)mi->on_enter; + + camera_update(); + SCX_REG = (u8)g.cam_x; + SCY_REG = (u8)g.cam_y; + + // LCD on | WIN map 0x9C00 (bit6) | WIN off (bit5) | BG data signed (bit4=0) + // | BG map 0x9800 (bit3=0) | OBJ 8x16 (bit2) | OBJ on | BG on + LCDC_REG = 0xC7; +} + +u8 map_solid(s16 tx, s16 ty) { + u8 i; + if (tx < 0 || ty < 0 || tx >= (s16)g.map_w || ty >= (s16)g.map_h) return 1; + SWITCH_ROM(g.map_bank); + if (g.map_coll[(u16)ty * g.map_w + (u16)tx]) return 1; + for (i = 0; i < g.n_actors; i++) { + if ((pj_actors[i].flags & ACTOR_FLAG_SOLID) && (s16)pj_actors[i].x == tx && (s16)pj_actors[i].y == ty) return 1; + } + return 0; +} + +s8 map_actor_at(s16 tx, s16 ty) { + u8 i; + for (i = 0; i < g.n_actors; i++) { + if ((s16)pj_actors[i].x == tx && (s16)pj_actors[i].y == ty) return (s8)i; + } + return -1; +} + +// --- player --------------------------------------------------------------------- +void player_update(void) { + if (!g.moving && !g.locked) { + if (key_pressed(PJK_A)) { + s16 tx = g.px >> 3, ty = g.py >> 3; + s16 fx = tx + DX[g.dir], fy = ty + DY[g.dir]; + s8 slot = map_actor_at(fx, fy); + if (slot >= 0 && pj_actors[(u8)slot].on_talk != PJGB_SCRIPT_NONE) { + vm_start((u8)pj_actors[(u8)slot].on_talk, slot); + return; + } + } + { + s8 dir = -1; + if (key_held(PJK_DOWN)) dir = DIR_DOWN; + else if (key_held(PJK_UP)) dir = DIR_UP; + else if (key_held(PJK_LEFT)) dir = DIR_LEFT; + else if (key_held(PJK_RIGHT)) dir = DIR_RIGHT; + if (dir >= 0) { + s16 nx, ny; + g.dir = (u8)dir; + nx = (g.px >> 3) + DX[g.dir]; + ny = (g.py >> 3) + DY[g.dir]; + if (!map_solid(nx, ny)) g.moving = 1; + } + } + } + + if (g.moving) { + g.px += DX[g.dir] * PLAYER_SPEED; + g.py += DY[g.dir] * PLAYER_SPEED; + if (++g.anim_timer >= ANIM_RATE) { + g.anim_timer = 0; + g.anim_frame++; + } + if (((g.px & 7) == 0) && ((g.py & 7) == 0)) { + u8 i; + s16 tx = g.px >> 3, ty = g.py >> 3; + g.moving = 0; + for (i = 0; i < g.n_warps; i++) { + if ((s16)pj_warps[i].x == tx && (s16)pj_warps[i].y == ty) { + map_enter(pj_warps[i].dest_map, (u8)pj_warps[i].dest_x, (u8)pj_warps[i].dest_y, pj_warps[i].dest_dir); + return; + } + } + } + } +} + +// --- camera ---------------------------------------------------------------------- +static s16 clamp16(s16 v, s16 lo, s16 hi) { + if (v < lo) return lo; + if (v > hi) return hi; + return v; +} + +void camera_update(void) { + s16 max_x = (s16)g.map_w * 8 - PJGB_SCREEN_W; + s16 max_y = (s16)g.map_h * 8 - PJGB_SCREEN_H; + if (max_x < 0) max_x = 0; + if (max_y < 0) max_y = 0; + g.cam_x = clamp16(g.px + 4 - PJGB_SCREEN_W / 2, 0, max_x); + g.cam_y = clamp16(g.py + 4 - PJGB_SCREEN_H / 2, 0, max_y); +} + +// --- OAM scene --------------------------------------------------------------------- +// 8x16 mode: each 16x16 character = 2 hardware sprites (left, right halfcell). +static void put_char_sprite(u8 hw, u16 tile, s16 sx, s16 sy) { + if (sx <= -16 || sx >= PJGB_SCREEN_W || sy <= -16 || sy >= PJGB_SCREEN_H) { + move_sprite(hw, 0, 0); + move_sprite(hw + 1, 0, 0); + return; + } + set_sprite_tile(hw, (u8)tile); + set_sprite_tile(hw + 1, (u8)(tile + 2)); + move_sprite(hw, (u8)(sx + 8), (u8)(sy + 16)); + move_sprite(hw + 1, (u8)(sx + 16), (u8)(sy + 16)); +} + +void scene_draw(void) { + u8 i; + // player (sprite id 0) -> hw sprites 0,1 + { + const PjSprite *sp = &pj_sprites[0]; + u8 frames = sp->frames ? sp->frames : 1; + u16 tile = sp->tile_base + (u16)g.dir * frames * 4 + (u16)(g.anim_frame % frames) * 4; + put_char_sprite(0, tile, g.px - g.cam_x - 4, g.py - g.cam_y - 8); + } + for (i = 0; i < g.n_actors; i++) { + u8 hw = 2 + i * 2; + if (pj_actors[i].sprite == 0xff) { + move_sprite(hw, 0, 0); + move_sprite(hw + 1, 0, 0); + continue; + } + { + const PjSprite *sp = &pj_sprites[pj_actors[i].sprite]; + u8 frames = sp->frames ? sp->frames : 1; + u16 tile = sp->tile_base + (u16)g.actor_dir[i] * frames * 4 + (u16)(g.actor_frame[i] % frames) * 4; + put_char_sprite(hw, tile, (s16)pj_actors[i].x * 8 - g.cam_x - 4, (s16)pj_actors[i].y * 8 - g.cam_y - 8); + } + } + // hide the rest + for (i = 2 + g.n_actors * 2; i < 40; i++) move_sprite(i, 0, 0); +} + +// --- input ------------------------------------------------------------------------ +void input_poll(void) { + u8 j = joypad(); + u8 k = 0; + if (j & J_A) k |= PJK_A; + if (j & J_B) k |= PJK_B; + if (j & J_SELECT) k |= PJK_SELECT; + if (j & J_START) k |= PJK_START; + if (j & J_RIGHT) k |= PJK_RIGHT; + if (j & J_LEFT) k |= PJK_LEFT; + if (j & J_UP) k |= PJK_UP; + if (j & J_DOWN) k |= PJK_DOWN; + g.keys_prev = g.keys; + g.keys = k; +} + +u8 key_held(u8 mask) { return (g.keys & mask) != 0; } +u8 key_pressed(u8 mask) { return (g.keys & mask) != 0 && (g.keys_prev & mask) == 0; } + +// --- debug block --------------------------------------------------------------------- +#define DBG8(off) (*(volatile u8 *)(PJGB_DEBUG_ADDR + (off))) +#define DBG16(off) (*(volatile u16 *)(PJGB_DEBUG_ADDR + (off))) + +void debug_flush(void) { + u8 i; + DBG16(DBG_MAGIC) = (u16)(DEBUG_MAGIC & 0xffff); + DBG16(DBG_MAGIC + 2) = (u16)((u32)DEBUG_MAGIC >> 16); + DBG16(DBG_PLAYER_X) = (u16)(g.px >> 3); + DBG16(DBG_PLAYER_Y) = (u16)(g.py >> 3); + DBG8(DBG_PLAYER_DIR) = g.dir; + DBG8(DBG_CUR_MAP) = g.map_id; + DBG8(DBG_TEXT_ACTIVE) = g.text_active; + DBG8(DBG_SCRIPT_ACTIVE) = vm_active(); + DBG16(DBG_FRAME) = g.frame; + DBG16(DBG_FRAME + 2) = 0; + DBG16(DBG_CUR_TEXT) = g.text_active ? g.cur_text : 0xFFFF; + DBG8(DBG_CHOICE_CURSOR) = g.choice_cursor; + DBG8(DBG_BOOTED) = 1; + for (i = 0; i < 16; i++) DBG8(DBG_FLAGS + i) = g.flags[i]; + for (i = 0; i < 16; i++) DBG16(DBG_VARS + i * 2) = (u16)g.vars[i]; +} diff --git a/aot/runtime/gb/gbrt.h b/aot/runtime/gb/gbrt.h new file mode 100644 index 0000000..a118c79 --- /dev/null +++ b/aot/runtime/gb/gbrt.h @@ -0,0 +1,138 @@ +// aot/runtime/gb/gbrt.h — internal contract for the PocketJS-AOT Game Boy +// runtime (GBDK-2020 / SDCC, DMG-compatible). +// +// MEMORY / VRAM PLAN: +// LCDC: BG on @ 0x9800, WINDOW (textbox) @ 0x9C00, OBJ 8x16. +// BG tile data uses SIGNED addressing (LCDC.4 = 0): ids 0..127 -> 0x9000, +// ids 128..255 -> 0x8800. OBJ tiles own 0x8000..0x87FF (128 tiles). +// BG ids: 0 = blank, 1.. tileset, PJ_BOX_TILE, then PJ_SLOT_BASE.. glyph +// slots (2 tiles per halfcell), all sized by the compiler. +// +// DATA ACCESS: all game data lives in autobanked ROM (gen/*.c). Every read +// switches with SWITCH_ROM(BANK(sym)). Actors/warps of the current map are +// copied to WRAM on map_enter; script bytecode is copied to a WRAM buffer on +// vm_start, so the VM never touches banks mid-run. +#ifndef PJ_GBRT_H +#define PJ_GBRT_H + +#include +#include +#include "pjgb_gen.h" +#include "gen_data.h" + +typedef uint8_t u8; +typedef uint16_t u16; +typedef int8_t s8; +typedef int16_t s16; +typedef uint32_t u32; + +// --- keys (PJ layout, matches the GBA runtime + harness expectations) ------- +#define PJK_A 0x01 +#define PJK_B 0x02 +#define PJK_SELECT 0x04 +#define PJK_START 0x08 +#define PJK_RIGHT 0x10 +#define PJK_LEFT 0x20 +#define PJK_UP 0x40 +#define PJK_DOWN 0x80 + +// --- VM ---------------------------------------------------------------------- +enum { VM_SUSP_NONE = 0, VM_SUSP_TEXT, VM_SUSP_CHOICE, VM_SUSP_WAIT }; + +typedef struct { + u8 active; + u8 suspend; + u16 ip; + s16 stack[PJGB_VM_MAX_STACK]; + u8 sp; + u16 wait_frames; + s8 actor_slot; +} PjVm; + +// --- global game state -------------------------------------------------------- +typedef struct { + u8 map_id; + u8 map_w, map_h; + u8 map_bank; // bank of the current map's tiles/coll arrays + const u8 *map_tiles; + const u8 *map_coll; + u8 n_actors, n_warps; + u8 on_enter; + + s16 px, py; // player pixel position (world) + u8 dir; + u8 moving; + u8 anim_frame, anim_timer; + u8 locked; + + s16 cam_x, cam_y; + + u8 actor_dir[BUDGET_MAX_ACTORS_PER_MAP]; + u8 actor_frame[BUDGET_MAX_ACTORS_PER_MAP]; + + PjVm vm; + s8 pending_enter; + + u8 text_active; + u16 cur_text; + u8 choice_active; + u8 choice_n; + u16 choice_ids[8]; + u8 choice_cursor; + s8 choice_result; + + u8 flags[16]; + s16 vars[16]; + + u16 slot_next; + u16 rng; + + u8 keys, keys_prev; + u16 frame; +} PjGame; + +extern PjGame g; +// WRAM copies of the current map's entities + the running script. +extern PjActor pj_actors[BUDGET_MAX_ACTORS_PER_MAP]; +extern PjWarp pj_warps[8]; +extern u8 pj_script_ram[PJ_SCRIPT_BUF]; + +// --- gbrt.c ------------------------------------------------------------------- +void video_boot(void); +void map_enter(u8 map_id, u8 tx, u8 ty, u8 dir); +u8 map_solid(s16 tx, s16 ty); +s8 map_actor_at(s16 tx, s16 ty); +void player_update(void); +void camera_update(void); +void scene_draw(void); // OAM shadow for player + actors +void input_poll(void); +u8 key_held(u8 mask); +u8 key_pressed(u8 mask); +void debug_flush(void); + +// VRAM helpers (safe contexts only: vblank pump or LCD off) +u8 *bg_tile_addr(u8 id); + +// --- vm.c --------------------------------------------------------------------- +void vm_start(u8 script_id, s8 actor_slot); +void vm_tick(void); +u8 vm_active(void); + +// --- textbox.c ------------------------------------------------------------------ +void textbox_init(void); +void textbox_show(u16 text_id); +void textbox_hide(void); +u8 textbox_active(void); +void textbox_tick(void); +void textbox_pump(void); // call right after wait_vbl_done(): streams VRAM work +void choice_show(u8 n, const u16 *text_ids); +u8 choice_active(void); +void choice_tick(void); +s8 choice_result(void); + +// --- flags --------------------------------------------------------------------- +#define flag_get(id) ((g.flags[(id) >> 3] >> ((id) & 7)) & 1) +#define flag_set1(id) (g.flags[(id) >> 3] |= (u8)(1 << ((id) & 7))) +#define flag_set0(id) (g.flags[(id) >> 3] &= (u8)~(1 << ((id) & 7))) + +#endif diff --git a/aot/runtime/gb/main.c b/aot/runtime/gb/main.c new file mode 100644 index 0000000..9d97609 --- /dev/null +++ b/aot/runtime/gb/main.c @@ -0,0 +1,33 @@ +// aot/runtime/gb/main.c — entry point + per-frame loop (Game Boy). +#include "gbrt.h" + +void main(void) { + video_boot(); + textbox_init(); + g.pending_enter = -1; + map_enter(PJ_START_MAP, PJ_START_X, PJ_START_Y, PJ_START_DIR); + debug_flush(); + + while (1) { + wait_vbl_done(); + // vblank-only work first: scroll regs + queued VRAM writes + SCX_REG = (u8)g.cam_x; + SCY_REG = (u8)g.cam_y; + textbox_pump(); + + input_poll(); + if (!vm_active() && g.pending_enter >= 0) { + u8 sid = (u8)g.pending_enter; + g.pending_enter = -1; + vm_start(sid, -1); + } + vm_tick(); + if (!vm_active()) player_update(); + textbox_tick(); + choice_tick(); + camera_update(); + scene_draw(); + debug_flush(); + g.frame++; + } +} diff --git a/aot/runtime/gb/pjgb_gen.h b/aot/runtime/gb/pjgb_gen.h new file mode 100644 index 0000000..38ee190 --- /dev/null +++ b/aot/runtime/gb/pjgb_gen.h @@ -0,0 +1,160 @@ +// GENERATED by aot/spec/gen-c.ts from aot/spec/pjgb.ts (gb) — DO NOT EDIT. +#ifndef PJGB_GEN_H +#define PJGB_GEN_H + +/* ---------------------------------------------------------------------- + * Target + * ---------------------------------------------------------------------- */ + +#define PJGB_SCREEN_W 0xa0 +#define PJGB_SCREEN_H 0x90 +#define PJGB_SCREEN_TILES_W 0x14 +#define PJGB_SCREEN_TILES_H 0x12 +#define PJGB_MAX_MAP_W 0x20 +#define PJGB_MAX_MAP_H 0x20 +#define PJGB_TILE_BYTES 0x10 +#define PJGB_TILE_4BPP_BYTES 0x20 +#define PJGB_TILE_2BPP_BYTES 0x10 + +/* ---------------------------------------------------------------------- + * Text (cjk16 metrics; layout anchors live in the runtime) + * ---------------------------------------------------------------------- */ + +#define PJGB_TEXT_COLS 0x12 +#define PJGB_TEXT_LINES 0x2 +#define PJGB_TEXT_CHOICE_COLS 0x12 +#define PJGB_TEXT_MAX_CHOICES 0x4 +#define PJGB_TEXT_GLYPH_SLOTS 0x48 +#define TEXT_MODE_ASCII8 0x0 +#define TEXT_MODE_CJK16 0x1 +#define TOK_END 0x0 +#define TOK_NEWLINE 0xa +#define TOK_ASCII_MIN 0x20 +#define TOK_ASCII_MAX 0x7e +#define TOK_FULL_FLAG 0x80 +#define PJGB_HALF_GLYPH_COUNT 0x5f +#define PJGB_GLYPH_STORE_HEADER_SIZE 0x8 + +/* ---------------------------------------------------------------------- + * Container header + chunks (GBA parses; GB/NES get gen_data) + * ---------------------------------------------------------------------- */ + +#define PJGB_VERSION 0x1 +#define PJGB_HEADER_SIZE 0x10 +#define PJGB_CHUNK_ENTRY_SIZE 0x10 +#define CHUNK_GAME 0x1 +#define CHUNK_PAL_BG 0x2 +#define CHUNK_PAL_OBJ 0x3 +#define CHUNK_TILES_BG 0x4 +#define CHUNK_TILES_OBJ 0x5 +#define CHUNK_MAP 0x6 +#define CHUNK_TEXT_BANK 0x7 +#define CHUNK_SCRIPT_CODE 0x8 +#define CHUNK_SCRIPT_TABLE 0x9 +#define CHUNK_SPRITE_TABLE 0xa +#define CHUNK_GLYPHS 0xb + +/* ---------------------------------------------------------------------- + * Struct sizes / field constants + * ---------------------------------------------------------------------- */ + +#define PJGB_GAME_TITLE_LEN 0x18 +#define PJGB_GAME_HEADER_SIZE 0x30 +#define PJGB_MAP_HEADER_SIZE 0x1c +#define PJGB_ACTOR_INSTANCE_SIZE 0xc +#define PJGB_WARP_SIZE 0xc +#define PJGB_SPRITE_PROTO_SIZE 0x8 +#define PJGB_SCRIPT_NONE 0xffff +#define PJGB_COLLISION_WALKABLE 0x0 +#define PJGB_COLLISION_SOLID 0x1 + +/* ---------------------------------------------------------------------- + * Directions / movement / actor flags + * ---------------------------------------------------------------------- */ + +#define DIR_DOWN 0x0 +#define DIR_UP 0x1 +#define DIR_LEFT 0x2 +#define DIR_RIGHT 0x3 +#define MOVE_STATIC 0x0 +#define MOVE_WANDER 0x1 +#define MOVE_PATROL_H 0x2 +#define MOVE_PATROL_V 0x3 +#define ACTOR_FLAG_NONE 0x0 +#define ACTOR_FLAG_SOLID 0x1 + +/* ---------------------------------------------------------------------- + * Script VM + * ---------------------------------------------------------------------- */ + +#define OP_END 0x0 +#define OP_NOP 0x1 +#define OP_TEXT 0x2 +#define OP_SET_FLAG 0x3 +#define OP_CLEAR_FLAG 0x4 +#define OP_PUSH_FLAG 0x5 +#define OP_PUSH_CONST 0x6 +#define OP_POP 0x7 +#define OP_DUP 0x8 +#define OP_EQ 0x9 +#define OP_NE 0xa +#define OP_NOT 0xb +#define OP_JUMP 0xc +#define OP_JUMP_IF_FALSE 0xd +#define OP_CHOICE 0xe +#define OP_LOCK_PLAYER 0xf +#define OP_RELEASE_PLAYER 0x10 +#define OP_FACE_PLAYER 0x11 +#define OP_WARP 0x12 +#define OP_SET_VAR 0x13 +#define OP_ADD_VAR 0x14 +#define OP_PUSH_VAR 0x15 +#define OP_GIVE_ITEM 0x16 +#define OP_BATTLE 0x17 +#define OP_WAIT 0x18 +#define OP_PLAY_SFX 0x19 +#define OP_LT 0x1a +#define OP_GT 0x1b +#define OP_LE 0x1c +#define OP_GE 0x1d +#define OP_RND 0x1e +#define PJGB_VM_MAX_STACK 0x10 + +/* ---------------------------------------------------------------------- + * Debug block (fixed RAM address for the emulator harness) + * ---------------------------------------------------------------------- */ + +#define PJGB_DEBUG_ADDR 0xde00 +#define PJGB_DEBUG_BLOCK_SIZE 0x44 +#define DEBUG_MAGIC 0x504a4442 +#define DBG_MAGIC 0x0 +#define DBG_PLAYER_X 0x4 +#define DBG_PLAYER_Y 0x6 +#define DBG_PLAYER_DIR 0x8 +#define DBG_CUR_MAP 0x9 +#define DBG_TEXT_ACTIVE 0xa +#define DBG_SCRIPT_ACTIVE 0xb +#define DBG_FRAME 0xc +#define DBG_CUR_TEXT 0x10 +#define DBG_CHOICE_CURSOR 0x12 +#define DBG_BOOTED 0x13 +#define DBG_FLAGS 0x14 +#define DBG_VARS 0x24 + +/* ---------------------------------------------------------------------- + * Budgets + * ---------------------------------------------------------------------- */ + +#define BUDGET_MAX_ACTORS_PER_MAP 0x18 +#define BUDGET_MAX_MAPS 0x20 +#define BUDGET_MAX_SPRITES 0x10 +#define BUDGET_MAX_FLAGS 0x80 +#define BUDGET_MAX_VARS 0x10 +#define BUDGET_MAX_TEXTS 0x200 +#define BUDGET_MAX_SCRIPTS 0xfe +#define BUDGET_MAX_BG_TILES 0x200 +#define BUDGET_MAX_OBJ_TILES 0x400 +#define BUDGET_MAX_MAP_TILES 0x4000 +#define BUDGET_MAX_FULL_GLYPHS 0x200 + +#endif /* PJGB_GEN_H */ diff --git a/aot/runtime/gb/textbox.c b/aot/runtime/gb/textbox.c new file mode 100644 index 0000000..10a52e8 --- /dev/null +++ b/aot/runtime/gb/textbox.c @@ -0,0 +1,235 @@ +// aot/runtime/gb/textbox.c — WINDOW-layer textbox + choice menu (cjk16 only). +// +// DMG VRAM is only reliably writable during vblank, so all rendering goes +// through a per-frame "pump" (textbox_pump, called right after +// wait_vbl_done): first the box rows are filled, then glyph halfcells are +// streamed 2 per frame — a natural typewriter reveal. Glyph pixel data is +// copied from banked ROM into the reserved BG tile slot region +// (PJ_SLOT_BASE..), and the window map points at the freshly-written tiles. +// +// Choice menus reserve slot 0 for the '>' cursor so moving the cursor is two +// map-byte writes, not a re-render. +#include +#include "gbrt.h" + +#define WIN_MAP ((u8 *)0x9C00) +#define WIN_COLS 20 + +// text layout inside the window (rows are window-map rows) +#define TB_TEXT_ROW0 1 +#define TB_TEXT_COL0 1 +#define TB_CHOICE_CURSOR_COL 1 +#define TB_CHOICE_TEXT_COL 3 + +#define MAX_JOBS 5 +#define TOKBUF 160 + +typedef struct { + u16 text_id; + u8 row, col; +} TbJob; + +static TbJob jobs[MAX_JOBS]; +static u8 n_jobs, cur_job; +static u8 tokbuf[TOKBUF]; +static u8 tok_pos, tok_loaded; +static u8 cur_row, cur_col; +static u8 box_rows, fill_row; +static u8 pump_done; +static u8 cursor_slot_ready; +static u8 cursor_row_prev; + +static void load_tokens(u16 text_id) { + u16 off; + u8 i; + SWITCH_ROM(BANK(pj_texts)); + off = pj_text_offs[text_id]; + for (i = 0; i < TOKBUF - 1; i++) { + tokbuf[i] = pj_texts[off + i]; + if (tokbuf[i] == TOK_END) break; + } + tokbuf[TOKBUF - 1] = TOK_END; + tok_pos = 0; + tok_loaded = 1; +} + +// Copy one halfcell (2 tiles = 32 bytes) into the next slot and point the +// window map cell (row, col) at it. set_bkg_data/set_win_tile_xy are the +// GBDK STAT-aware writers, safe with the LCD on (DMG drops plain writes +// during mode 3, which shows up as missing glyph rows). +static void draw_halfcell_from(const u8 *src_banked, u8 bank, u8 row, u8 col, u16 slot) { + u8 tile = (u8)(PJ_SLOT_BASE + slot * 2); + SWITCH_ROM(bank); + set_bkg_data(tile, 2, src_banked); + set_win_tile_xy(col, row, tile); + set_win_tile_xy(col, row + 1, tile + 1); +} + +static u16 alloc_slot(void) { + u16 s = g.slot_next; + if (s * 2 + 2 > (u16)PJGB_TEXT_GLYPH_SLOTS * 2) return 0; // overflow: reuse 0 + g.slot_next++; + return s; +} + +// Draw the next halfcell token; returns 0 when the current stream is done. +static u8 pump_token(void) { + u8 tok; + if (!tok_loaded) return 0; + tok = tokbuf[tok_pos]; + if (tok == TOK_END) return 0; + tok_pos++; + if (tok == TOK_NEWLINE) { + cur_row += 2; + cur_col = jobs[cur_job].col; + return 1; + } + if (tok & TOK_FULL_FLAG) { + u16 id = (((u16)(tok & 0x3f)) << 8) | tokbuf[tok_pos++]; + u16 off = id << 6; // 64 bytes per fullwidth glyph + u16 s0 = alloc_slot(), s1 = alloc_slot(); + draw_halfcell_from(pj_glyphs_full + off, BANK(pj_glyphs_full), cur_row, cur_col, s0); + draw_halfcell_from(pj_glyphs_full + off + 32, BANK(pj_glyphs_full), cur_row, cur_col + 1, s1); + cur_col += 2; + } else { + u16 s0 = alloc_slot(); + draw_halfcell_from(pj_glyphs_half + (u16)(tok - TOK_ASCII_MIN) * 32, BANK(pj_glyphs_half), cur_row, cur_col, s0); + cur_col += 1; + } + return 1; +} + +static void open_box(u8 rows) { + box_rows = rows; + fill_row = 0; + pump_done = 0; + cur_job = 0; + tok_loaded = 0; + g.slot_next = 0; + cursor_slot_ready = 0; + WY_REG = (u8)((PJGB_SCREEN_TILES_H - rows) * 8); + WX_REG = 7; + LCDC_REG |= 0x20; // window on +} + +void textbox_init(void) { + g.text_active = 0; + g.choice_active = 0; + g.choice_result = -1; + pump_done = 1; + n_jobs = 0; +} + +void textbox_show(u16 text_id) { + g.cur_text = text_id; + g.text_active = 1; + g.choice_n = 0; + n_jobs = 1; + jobs[0].text_id = text_id; + jobs[0].row = TB_TEXT_ROW0; + jobs[0].col = TB_TEXT_COL0; + open_box((u8)(PJGB_TEXT_LINES * 2 + 2)); +} + +void textbox_hide(void) { + g.text_active = 0; + n_jobs = 0; + pump_done = 1; + LCDC_REG &= ~0x20; // window off + WY_REG = 144; +} + +u8 textbox_active(void) { return g.text_active; } + +void textbox_tick(void) { + if (g.text_active && !g.choice_active && key_pressed(PJK_A)) textbox_hide(); +} + +// --- choice ------------------------------------------------------------------ +void choice_show(u8 n, const u16 *text_ids) { + u8 i; + g.choice_active = 1; + g.choice_n = n; + g.choice_cursor = 0; + g.choice_result = -1; + for (i = 0; i < n && i < 8; i++) g.choice_ids[i] = text_ids[i]; + g.text_active = 1; // box is on screen (parity with GBA semantics) + n_jobs = n; + for (i = 0; i < n; i++) { + jobs[i].text_id = g.choice_ids[i]; + jobs[i].row = (u8)(TB_TEXT_ROW0 + i * 2); + jobs[i].col = TB_CHOICE_TEXT_COL; + } + open_box((u8)(n * 2 + 2)); + g.slot_next = 1; // slot 0 is reserved for the cursor glyph + cursor_row_prev = TB_TEXT_ROW0; +} + +u8 choice_active(void) { return g.choice_active; } +s8 choice_result(void) { return g.choice_result; } + +static void cursor_cells(u8 row, u8 top, u8 bottom) { + set_win_tile_xy(TB_CHOICE_CURSOR_COL, row, top); + set_win_tile_xy(TB_CHOICE_CURSOR_COL, row + 1, bottom); +} + +void choice_tick(void) { + if (!g.choice_active) return; + if (key_pressed(PJK_UP) && g.choice_cursor > 0) g.choice_cursor--; + else if (key_pressed(PJK_DOWN) && g.choice_cursor < g.choice_n - 1) g.choice_cursor++; + if (key_pressed(PJK_A)) { + g.choice_result = (s8)g.choice_cursor; + g.choice_active = 0; + textbox_hide(); + } +} + +// --- the vblank pump ----------------------------------------------------------- +void textbox_pump(void) { + u8 budget; + if (!g.text_active && !g.choice_active) return; + + // 1) box fill: two rows per frame + for (budget = 0; budget < 2 && fill_row < box_rows; budget++) { + fill_win_rect(0, fill_row, WIN_COLS, 1, PJ_BOX_TILE); + fill_row++; + } + if (fill_row < box_rows) return; + + // 2) choice cursor: draw the '>' glyph once into slot 0, then track moves + if (g.choice_active || g.choice_n) { + if (g.choice_active && !cursor_slot_ready) { + draw_halfcell_from(pj_glyphs_half + (u16)('>' - TOK_ASCII_MIN) * 32, BANK(pj_glyphs_half), + (u8)(TB_TEXT_ROW0 + g.choice_cursor * 2), TB_CHOICE_CURSOR_COL, 0); + cursor_slot_ready = 1; + cursor_row_prev = (u8)(TB_TEXT_ROW0 + g.choice_cursor * 2); + } else if (g.choice_active && cursor_slot_ready) { + u8 row = (u8)(TB_TEXT_ROW0 + g.choice_cursor * 2); + if (row != cursor_row_prev) { + cursor_cells(cursor_row_prev, PJ_BOX_TILE, PJ_BOX_TILE); // erase old + cursor_cells(row, PJ_SLOT_BASE, PJ_SLOT_BASE + 1); // slot 0 tiles + cursor_row_prev = row; + } + } + } + + // 3) glyph streaming: two halfcells per frame + if (pump_done) return; + for (budget = 0; budget < 2;) { + if (!tok_loaded) { + if (cur_job >= n_jobs) { + pump_done = 1; + return; + } + load_tokens(jobs[cur_job].text_id); + cur_row = jobs[cur_job].row; + cur_col = jobs[cur_job].col; + } + if (!pump_token()) { + tok_loaded = 0; + cur_job++; + continue; + } + budget++; + } +} diff --git a/aot/runtime/gb/vm.c b/aot/runtime/gb/vm.c new file mode 100644 index 0000000..b3feb34 --- /dev/null +++ b/aot/runtime/gb/vm.c @@ -0,0 +1,253 @@ +// aot/runtime/gb/vm.c — the event-script stack machine (Game Boy port). +// +// Bytecode is copied from banked ROM into a WRAM buffer at vm_start, so the +// interpreter never juggles banks mid-run. Semantics mirror the GBA +// runtime/gba/script_vm.c op for op (the cross-target contract is the E2E +// suite driving identical scenarios on both). +#include +#include "gbrt.h" + +static u8 rd_u8(void) { return pj_script_ram[g.vm.ip++]; } +static u16 rd_u16(void) { + u16 v = pj_script_ram[g.vm.ip] | ((u16)pj_script_ram[g.vm.ip + 1] << 8); + g.vm.ip += 2; + return v; +} +static s16 rd_i16(void) { return (s16)rd_u16(); } + +static void push(s16 v) { + if (g.vm.sp < PJGB_VM_MAX_STACK) g.vm.stack[g.vm.sp++] = v; +} +static s16 pop(void) { + if (g.vm.sp > 0) return g.vm.stack[--g.vm.sp]; + return 0; +} + +static void face_player(s8 slot) { + s16 ax, ay, px, py, dx, dy, adx, ady; + u8 dir; + if (slot < 0 || slot >= (s8)g.n_actors) return; + ax = (s16)pj_actors[(u8)slot].x; + ay = (s16)pj_actors[(u8)slot].y; + px = g.px >> 3; + py = g.py >> 3; + dx = px - ax; + dy = py - ay; + adx = dx < 0 ? -dx : dx; + ady = dy < 0 ? -dy : dy; + if (adx >= ady) dir = dx > 0 ? DIR_RIGHT : DIR_LEFT; + else dir = dy > 0 ? DIR_DOWN : DIR_UP; + g.actor_dir[(u8)slot] = dir; +} + +void vm_start(u8 script_id, s8 actor_slot) { + u16 off, end; + SWITCH_ROM(BANK(pj_scripts)); + off = pj_script_offs[script_id]; + end = pj_script_offs[script_id + 1]; + memcpy(pj_script_ram, pj_scripts + off, end - off); + g.vm.ip = 0; + g.vm.sp = 0; + g.vm.active = 1; + g.vm.suspend = VM_SUSP_NONE; + g.vm.wait_frames = 0; + g.vm.actor_slot = actor_slot; +} + +u8 vm_active(void) { return g.vm.active; } + +void vm_tick(void) { + if (!g.vm.active) return; + + switch (g.vm.suspend) { + case VM_SUSP_WAIT: + if (--g.vm.wait_frames == 0) g.vm.suspend = VM_SUSP_NONE; + else return; + break; + case VM_SUSP_TEXT: + if (!textbox_active()) g.vm.suspend = VM_SUSP_NONE; + else return; + break; + case VM_SUSP_CHOICE: + if (choice_result() >= 0) { + push((s16)choice_result()); + g.vm.suspend = VM_SUSP_NONE; + } else { + return; + } + break; + default: + break; + } + + for (;;) { + u8 op = rd_u8(); + switch (op) { + case OP_END: + g.vm.active = 0; + return; + case OP_NOP: + break; + case OP_TEXT: { + u16 t = rd_u16(); + textbox_show(t); + g.vm.suspend = VM_SUSP_TEXT; + return; + } + case OP_SET_FLAG: { + u16 f = rd_u16(); + flag_set1(f); + break; + } + case OP_CLEAR_FLAG: { + u16 f = rd_u16(); + flag_set0(f); + break; + } + case OP_PUSH_FLAG: { + u16 f = rd_u16(); + push((s16)flag_get(f)); + break; + } + case OP_PUSH_CONST: + push(rd_i16()); + break; + case OP_POP: + pop(); + break; + case OP_DUP: { + s16 v = pop(); + push(v); + push(v); + break; + } + case OP_EQ: { + s16 b = pop(), a = pop(); + push(a == b ? 1 : 0); + break; + } + case OP_NE: { + s16 b = pop(), a = pop(); + push(a != b ? 1 : 0); + break; + } + case OP_NOT: { + s16 a = pop(); + push(a ? 0 : 1); + break; + } + case OP_JUMP: { + s16 rel = rd_i16(); + g.vm.ip = (u16)((s16)g.vm.ip + rel); + break; + } + case OP_JUMP_IF_FALSE: { + s16 rel = rd_i16(); + if (!pop()) g.vm.ip = (u16)((s16)g.vm.ip + rel); + break; + } + case OP_CHOICE: { + u8 n = rd_u8(); + u16 ids[8]; + u8 i; + for (i = 0; i < n; i++) { + u16 id = rd_u16(); + if (i < 8) ids[i] = id; + } + if (n > 8) n = 8; + choice_show(n, ids); + g.vm.suspend = VM_SUSP_CHOICE; + return; + } + case OP_LOCK_PLAYER: + g.locked = 1; + break; + case OP_RELEASE_PLAYER: + g.locked = 0; + break; + case OP_FACE_PLAYER: { + u8 slot = rd_u8(); + if (slot == 0xff) { + if (g.vm.actor_slot < 0) break; + slot = (u8)g.vm.actor_slot; + } + face_player((s8)slot); + break; + } + case OP_WARP: { + u8 m = rd_u8(); + u16 x = rd_u16(); + u16 y = rd_u16(); + u8 d = rd_u8(); + map_enter(m, (u8)x, (u8)y, d); + break; + } + case OP_SET_VAR: { + u16 i = rd_u16(); + s16 v = rd_i16(); + if (i < BUDGET_MAX_VARS) g.vars[i] = v; + break; + } + case OP_ADD_VAR: { + u16 i = rd_u16(); + s16 v = rd_i16(); + if (i < BUDGET_MAX_VARS) g.vars[i] += v; + break; + } + case OP_PUSH_VAR: { + u16 i = rd_u16(); + push(i < BUDGET_MAX_VARS ? g.vars[i] : 0); + break; + } + case OP_GIVE_ITEM: { + rd_u16(); + rd_u8(); // stub (parity with GBA) + break; + } + case OP_BATTLE: + rd_u16(); + push(1); // stub: "won" + break; + case OP_WAIT: { + u16 n = rd_u16(); + if (n == 0) break; + g.vm.wait_frames = n; + g.vm.suspend = VM_SUSP_WAIT; + return; + } + case OP_PLAY_SFX: + rd_u16(); + break; + case OP_LT: { + s16 b = pop(), a = pop(); + push(a < b ? 1 : 0); + break; + } + case OP_GT: { + s16 b = pop(), a = pop(); + push(a > b ? 1 : 0); + break; + } + case OP_LE: { + s16 b = pop(), a = pop(); + push(a <= b ? 1 : 0); + break; + } + case OP_GE: { + s16 b = pop(), a = pop(); + push(a >= b ? 1 : 0); + break; + } + case OP_RND: { + u8 n = rd_u8(); + if (g.rng == 0) g.rng = g.frame | 1; + g.rng = g.rng * 25173u + 13849u; + push(n ? (s16)((g.rng >> 4) % n) : 0); + break; + } + default: + g.vm.active = 0; + return; + } + } +} diff --git a/aot/runtime/actor.c b/aot/runtime/gba/actor.c similarity index 100% rename from aot/runtime/actor.c rename to aot/runtime/gba/actor.c diff --git a/aot/runtime/bg.c b/aot/runtime/gba/bg.c similarity index 100% rename from aot/runtime/bg.c rename to aot/runtime/gba/bg.c diff --git a/aot/runtime/build.sh b/aot/runtime/gba/build.sh similarity index 100% rename from aot/runtime/build.sh rename to aot/runtime/gba/build.sh diff --git a/aot/runtime/camera.c b/aot/runtime/gba/camera.c similarity index 100% rename from aot/runtime/camera.c rename to aot/runtime/gba/camera.c diff --git a/aot/runtime/cart.c b/aot/runtime/gba/cart.c similarity index 100% rename from aot/runtime/cart.c rename to aot/runtime/gba/cart.c diff --git a/aot/runtime/crt0.s b/aot/runtime/gba/crt0.s similarity index 100% rename from aot/runtime/crt0.s rename to aot/runtime/gba/crt0.s diff --git a/aot/runtime/debug.c b/aot/runtime/gba/debug.c similarity index 100% rename from aot/runtime/debug.c rename to aot/runtime/gba/debug.c diff --git a/aot/runtime/gba.h b/aot/runtime/gba/gba.h similarity index 100% rename from aot/runtime/gba.h rename to aot/runtime/gba/gba.h diff --git a/aot/runtime/gba.ld b/aot/runtime/gba/gba.ld similarity index 100% rename from aot/runtime/gba.ld rename to aot/runtime/gba/gba.ld diff --git a/aot/runtime/input.c b/aot/runtime/gba/input.c similarity index 100% rename from aot/runtime/input.c rename to aot/runtime/gba/input.c diff --git a/aot/runtime/main.c b/aot/runtime/gba/main.c similarity index 75% rename from aot/runtime/main.c rename to aot/runtime/gba/main.c index 4b3770f..8277d1c 100644 --- a/aot/runtime/main.c +++ b/aot/runtime/gba/main.c @@ -12,10 +12,17 @@ int main(void) { textbox_init(); debug_init(); + g.pending_enter = -1; map_enter(g.game->start_map, g.game->start_x, g.game->start_y, g.game->start_dir); for (;;) { input_poll(); + // A map's on-enter script starts as soon as no other script is running. + if (!vm_active() && g.pending_enter >= 0) { + int sid = g.pending_enter; + g.pending_enter = -1; + vm_start(sid, -1); + } vm_tick(); if (!vm_active()) player_update(); textbox_tick(); diff --git a/aot/runtime/map.c b/aot/runtime/gba/map.c similarity index 95% rename from aot/runtime/map.c rename to aot/runtime/gba/map.c index c34ddc2..cb75bff 100644 --- a/aot/runtime/map.c +++ b/aot/runtime/gba/map.c @@ -29,6 +29,8 @@ void map_enter(int map_id, int tx, int ty, int dir) { g.player.anim_timer = 0; g.player.sprite_id = 0; + g.pending_enter = (mh->on_enter == 0xff) ? -1 : (s16)mh->on_enter; + bg_load_map(); camera_follow(); bg_set_scroll(); diff --git a/aot/runtime/obj.c b/aot/runtime/gba/obj.c similarity index 100% rename from aot/runtime/obj.c rename to aot/runtime/gba/obj.c diff --git a/aot/runtime/pjgb_gen.h b/aot/runtime/gba/pjgb_gen.h similarity index 75% rename from aot/runtime/pjgb_gen.h rename to aot/runtime/gba/pjgb_gen.h index 67cb7b7..3ae981a 100644 --- a/aot/runtime/pjgb_gen.h +++ b/aot/runtime/gba/pjgb_gen.h @@ -1,20 +1,42 @@ -// GENERATED by aot/spec/gen-c.ts from aot/spec/pjgb.ts — DO NOT EDIT. +// GENERATED by aot/spec/gen-c.ts from aot/spec/pjgb.ts (gba) — DO NOT EDIT. #ifndef PJGB_GEN_H #define PJGB_GEN_H -#include /* ---------------------------------------------------------------------- - * Screen / tiles + * Target * ---------------------------------------------------------------------- */ #define PJGB_SCREEN_W 0xf0 #define PJGB_SCREEN_H 0xa0 #define PJGB_SCREEN_TILES_W 0x1e #define PJGB_SCREEN_TILES_H 0x14 +#define PJGB_MAX_MAP_W 0x20 +#define PJGB_MAX_MAP_H 0x20 +#define PJGB_TILE_BYTES 0x20 #define PJGB_TILE_4BPP_BYTES 0x20 +#define PJGB_TILE_2BPP_BYTES 0x10 /* ---------------------------------------------------------------------- - * Container header + chunks + * Text (cjk16 metrics; layout anchors live in the runtime) + * ---------------------------------------------------------------------- */ + +#define PJGB_TEXT_COLS 0x1c +#define PJGB_TEXT_LINES 0x3 +#define PJGB_TEXT_CHOICE_COLS 0x14 +#define PJGB_TEXT_MAX_CHOICES 0x4 +#define PJGB_TEXT_GLYPH_SLOTS 0x54 +#define TEXT_MODE_ASCII8 0x0 +#define TEXT_MODE_CJK16 0x1 +#define TOK_END 0x0 +#define TOK_NEWLINE 0xa +#define TOK_ASCII_MIN 0x20 +#define TOK_ASCII_MAX 0x7e +#define TOK_FULL_FLAG 0x80 +#define PJGB_HALF_GLYPH_COUNT 0x5f +#define PJGB_GLYPH_STORE_HEADER_SIZE 0x8 + +/* ---------------------------------------------------------------------- + * Container header + chunks (GBA parses; GB/NES get gen_data) * ---------------------------------------------------------------------- */ #define PJGB_VERSION 0x1 @@ -30,13 +52,14 @@ #define CHUNK_SCRIPT_CODE 0x8 #define CHUNK_SCRIPT_TABLE 0x9 #define CHUNK_SPRITE_TABLE 0xa +#define CHUNK_GLYPHS 0xb /* ---------------------------------------------------------------------- * Struct sizes / field constants * ---------------------------------------------------------------------- */ #define PJGB_GAME_TITLE_LEN 0x18 -#define PJGB_GAME_HEADER_SIZE 0x2c +#define PJGB_GAME_HEADER_SIZE 0x30 #define PJGB_MAP_HEADER_SIZE 0x1c #define PJGB_ACTOR_INSTANCE_SIZE 0xc #define PJGB_WARP_SIZE 0xc @@ -90,13 +113,17 @@ #define OP_BATTLE 0x17 #define OP_WAIT 0x18 #define OP_PLAY_SFX 0x19 +#define OP_LT 0x1a +#define OP_GT 0x1b +#define OP_LE 0x1c +#define OP_GE 0x1d +#define OP_RND 0x1e #define PJGB_VM_MAX_STACK 0x10 /* ---------------------------------------------------------------------- - * Debug block (fixed EWRAM address for the mGBA harness) + * Debug block (fixed RAM address for the emulator harness) * ---------------------------------------------------------------------- */ -#define PJGB_EWRAM_BASE 0x2000000 #define PJGB_DEBUG_ADDR 0x2000000 #define PJGB_DEBUG_BLOCK_SIZE 0x44 #define DEBUG_MAGIC 0x504a4442 @@ -124,9 +151,10 @@ #define BUDGET_MAX_FLAGS 0x80 #define BUDGET_MAX_VARS 0x10 #define BUDGET_MAX_TEXTS 0x200 -#define BUDGET_MAX_SCRIPTS 0x100 +#define BUDGET_MAX_SCRIPTS 0xfe #define BUDGET_MAX_BG_TILES 0x200 #define BUDGET_MAX_OBJ_TILES 0x400 #define BUDGET_MAX_MAP_TILES 0x4000 +#define BUDGET_MAX_FULL_GLYPHS 0x200 #endif /* PJGB_GEN_H */ diff --git a/aot/runtime/player.c b/aot/runtime/gba/player.c similarity index 100% rename from aot/runtime/player.c rename to aot/runtime/gba/player.c diff --git a/aot/runtime/runtime.h b/aot/runtime/gba/runtime.h similarity index 94% rename from aot/runtime/runtime.h rename to aot/runtime/gba/runtime.h index 52633be..bb47a66 100644 --- a/aot/runtime/runtime.h +++ b/aot/runtime/gba/runtime.h @@ -28,13 +28,15 @@ typedef struct { u16 start_x, start_y; u8 map_count, sprite_count; u16 flag_count, text_count, script_count; - u16 font_base, box_tile, rsv; + u16 font_base, box_tile; + u8 text_mode, rsv; + u16 glyph_slot_base, glyph_slot_count; } GameHeader; _Static_assert(sizeof(GameHeader) == PJGB_GAME_HEADER_SIZE, "GameHeader size"); typedef struct { u16 width, height, num_actors, num_warps; - u8 bg_palbank, on_load; + u8 bg_palbank, on_enter; u16 rsv; u32 tiles_off, collision_off, actors_off, warps_off; } MapHeader; @@ -121,6 +123,14 @@ typedef struct { u8 flags[16]; // 128 bits s16 vars[16]; + // deferred map on-enter script (set by map_enter, started by the main loop — + // map_enter can run from inside OP_WARP, where vm_start would clobber the VM) + s16 pending_enter; // script id or -1 + + // cjk16 glyph slot allocator + OP_RND state + u16 slot_next; + u16 rng; + u16 keys, keys_prev; u32 frame; } Game; diff --git a/aot/runtime/script_vm.c b/aot/runtime/gba/script_vm.c similarity index 89% rename from aot/runtime/script_vm.c rename to aot/runtime/gba/script_vm.c index 77e4c83..b223d2f 100644 --- a/aot/runtime/script_vm.c +++ b/aot/runtime/gba/script_vm.c @@ -221,6 +221,33 @@ void vm_tick(void) { (void)id; // stub break; } + case OP_LT: { + s16 b = pop(), a = pop(); + push(a < b ? 1 : 0); + break; + } + case OP_GT: { + s16 b = pop(), a = pop(); + push(a > b ? 1 : 0); + break; + } + case OP_LE: { + s16 b = pop(), a = pop(); + push(a <= b ? 1 : 0); + break; + } + case OP_GE: { + s16 b = pop(), a = pop(); + push(a >= b ? 1 : 0); + break; + } + case OP_RND: { + u8 n = rd_u8(); + if (g.rng == 0) g.rng = (u16)(g.frame | 1); + g.rng = (u16)(g.rng * 25173u + 13849u); + push(n ? (s16)((g.rng >> 4) % n) : 0); + break; + } default: // Unknown opcode: fail safe by ending the script. g.vm.active = 0; diff --git a/aot/runtime/gba/textbox.c b/aot/runtime/gba/textbox.c new file mode 100644 index 0000000..16742dd --- /dev/null +++ b/aot/runtime/gba/textbox.c @@ -0,0 +1,204 @@ +// aot/runtime/gba/textbox.c — BG1 textbox + choice menu (screenblock PJ_TEXT_SBB). +// +// Two text renderers, selected by GameHeader.text_mode: +// ascii8 — legacy: 8x8 ASCII glyphs baked as static BG tiles (font_base). +// cjk16 — 16px lines: glyph pixel data lives in the GLYPHS chunk and is +// streamed on demand into a reserved BG tile region ("slots", 1 +// halfcell = 2 stacked tiles). The compiler pre-wraps/paginates +// text, so a token stream always fits one page. +#include "runtime.h" + +// ascii8 metrics (historic layout, unchanged) +#define BOX_ROW0 12 +#define BOX_ROW1 19 +#define TEXT_COL0 1 +#define TEXT_ROW0 13 +#define TEXT_COLMAX 28 + +// cjk16 metrics: text lines are 2 tile rows tall inside the same box rows. +#define C16_COL0 1 +#define C16_ROW0 13 +#define C16_CHOICE_ROW0 12 +#define C16_CHOICE_COL_CURSOR 1 +#define C16_CHOICE_COL_TEXT 3 + +const char *text_get(int text_id) { + const u8 *chunk = cart_chunk(CHUNK_TEXT_BANK, 0, 0); + // u16 count, u16 rsv, u32 offsets[count] (from chunk start), then strings. + const u32 *offs = (const u32 *)(chunk + 4); + return (const char *)(chunk + offs[text_id]); +} + +static int cjk16(void) { return g.game->text_mode == TEXT_MODE_CJK16; } + +static void box_fill(void) { + u16 *sb = SCREENBLOCK(PJ_TEXT_SBB); + for (int row = BOX_ROW0; row <= BOX_ROW1; row++) + for (int col = 0; col < 30; col++) + sb[row * 32 + col] = SE(g.game->box_tile, 15); + g.slot_next = 0; +} + +static void put_char(u16 *sb, int row, int col, unsigned char c) { + if (c >= 0x20) sb[row * 32 + col] = SE(g.game->font_base + (c - 0x20), 15); +} + +// --- cjk16 glyph streaming --------------------------------------------------- + +// Copy one halfcell (2 stacked tiles) from the GLYPHS chunk into the next +// free VRAM slot and place it at (rowTop, col). Silently drops on overflow +// (the compiler sizes pages so this cannot happen). +static void draw_halfcell(u32 glyph_off, int rowTop, int col) { + u16 *sb; + if (g.slot_next * 2 + 2 > g.game->glyph_slot_count) return; + { + const u8 *store = cart_chunk(CHUNK_GLYPHS, 0, 0); + int tile = g.game->glyph_slot_base + g.slot_next * 2; + const u16 *src = (const u16 *)(store + glyph_off); + u16 *dst = CHARBLOCK(PJ_BG_CBB) + tile * (PJGB_TILE_4BPP_BYTES / 2); + for (int i = 0; i < PJGB_TILE_4BPP_BYTES; i++) dst[i] = src[i]; // 2 tiles + sb = SCREENBLOCK(PJ_TEXT_SBB); + sb[rowTop * 32 + col] = SE(tile, 15); + sb[(rowTop + 1) * 32 + col] = SE(tile + 1, 15); + g.slot_next++; + } +} + +static u32 half_glyph_off(int id) { + return PJGB_GLYPH_STORE_HEADER_SIZE + (u32)id * 2 * PJGB_TILE_4BPP_BYTES; +} +static u32 full_glyph_off(int id, int half) { + const u8 *store = cart_chunk(CHUNK_GLYPHS, 0, 0); + u16 half_count = *(const u16 *)store; + return PJGB_GLYPH_STORE_HEADER_SIZE + + ((u32)half_count * 2 + (u32)id * 4 + (u32)half * 2) * PJGB_TILE_4BPP_BYTES; +} + +// Render a whole token stream starting at (rowTop, col0). Newline advances +// one 16px line. The compiler guarantees the page fits. +static void render_tokens(const u8 *t, int rowTop, int col0) { + int row = rowTop, col = col0; + while (*t) { + u8 tok = *t++; + if (tok == TOK_NEWLINE) { + row += 2; + col = col0; + continue; + } + if (tok & TOK_FULL_FLAG) { + int id = ((tok & 0x3f) << 8) | *t++; + draw_halfcell(full_glyph_off(id, 0), row, col); + draw_halfcell(full_glyph_off(id, 1), row, col + 1); + col += 2; + } else { + draw_halfcell(half_glyph_off(tok - TOK_ASCII_MIN), row, col); + col += 1; + } + } +} + +// --- textbox ----------------------------------------------------------------- + +void textbox_init(void) { + g.text_active = 0; + g.choice_active = 0; + g.choice_result = -1; +} + +void textbox_show(int text_id) { + g.cur_text = (u16)text_id; + g.text_active = 1; + + box_fill(); + + if (cjk16()) { + render_tokens((const u8 *)text_get(text_id), C16_ROW0, C16_COL0); + } else { + u16 *sb = SCREENBLOCK(PJ_TEXT_SBB); + const char *t = text_get(text_id); + int col = TEXT_COL0, row = TEXT_ROW0; + for (const char *c = t; *c; c++) { + if (*c == '\n') { + row++; + col = TEXT_COL0; + if (row > BOX_ROW1) break; + continue; + } + if (col > TEXT_COLMAX) { + row++; + col = TEXT_COL0; + } + if (row > BOX_ROW1) break; + put_char(sb, row, col, (unsigned char)*c); + col++; + } + } + + REG_DISPCNT |= DCNT_BG1; +} + +void textbox_hide(void) { + g.text_active = 0; + REG_DISPCNT &= ~DCNT_BG1; +} + +int textbox_active(void) { return g.text_active; } + +void textbox_tick(void) { + if (g.text_active && !g.choice_active && key_pressed(KEY_A)) textbox_hide(); +} + +// --- choice menu ----------------------------------------------------------- +static void choice_render(void) { + box_fill(); + if (cjk16()) { + for (int i = 0; i < g.choice_n; i++) { + int row = C16_CHOICE_ROW0 + i * 2; + if (i == g.choice_cursor) draw_halfcell(half_glyph_off('>' - 0x20), row, C16_CHOICE_COL_CURSOR); + render_tokens((const u8 *)text_get(g.choice_ids[i]), row, C16_CHOICE_COL_TEXT); + } + return; + } + { + u16 *sb = SCREENBLOCK(PJ_TEXT_SBB); + for (int i = 0; i < g.choice_n; i++) { + int row = TEXT_ROW0 + i; + if (row > BOX_ROW1) break; + if (i == g.choice_cursor) put_char(sb, row, TEXT_COL0, '>'); + const char *t = text_get(g.choice_ids[i]); + int col = TEXT_COL0 + 2; + for (const char *c = t; *c && col <= TEXT_COLMAX; c++, col++) + put_char(sb, row, col, (unsigned char)*c); + } + } +} + +void choice_show(int n, const u16 *text_ids) { + g.choice_active = 1; + g.choice_n = (u8)n; + g.choice_cursor = 0; + g.choice_result = -1; + for (int i = 0; i < n && i < 8; i++) g.choice_ids[i] = text_ids[i]; + choice_render(); + REG_DISPCNT |= DCNT_BG1; +} + +int choice_active(void) { return g.choice_active; } + +int choice_result(void) { return g.choice_result; } + +void choice_tick(void) { + if (!g.choice_active) return; + if (key_pressed(KEY_UP) && g.choice_cursor > 0) { + g.choice_cursor--; + choice_render(); + } else if (key_pressed(KEY_DOWN) && g.choice_cursor < g.choice_n - 1) { + g.choice_cursor++; + choice_render(); + } + if (key_pressed(KEY_A)) { + g.choice_result = g.choice_cursor; + g.choice_active = 0; + textbox_hide(); + } +} diff --git a/aot/runtime/video.c b/aot/runtime/gba/video.c similarity index 100% rename from aot/runtime/video.c rename to aot/runtime/gba/video.c diff --git a/aot/runtime/gen_cart.c b/aot/runtime/gen_cart.c new file mode 100644 index 0000000..d5423d9 --- /dev/null +++ b/aot/runtime/gen_cart.c @@ -0,0 +1,305 @@ +// GENERATED by @pocketjs/aot — the PJGB cartridge blob linked into the ROM. +const unsigned char __attribute__((aligned(4))) pjgb_cart[] = { + 0x50,0x4a,0x47,0x42,0x01,0x00,0x0a,0x00,0x10,0x00,0x00,0x00,0xc8,0x12,0x00,0x00, + 0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xb0,0x00,0x00,0x00,0x30,0x00,0x00,0x00, + 0x02,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xe0,0x00,0x00,0x00,0x00,0x02,0x00,0x00, + 0x03,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xe0,0x02,0x00,0x00,0x20,0x00,0x00,0x00, + 0x04,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x03,0x00,0x00,0x80,0x0c,0x00,0x00, + 0x05,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x80,0x0f,0x00,0x00,0x00,0x02,0x00,0x00, + 0x0a,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x80,0x11,0x00,0x00,0x08,0x00,0x00,0x00, + 0x06,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x88,0x11,0x00,0x00,0xe8,0x00,0x00,0x00, + 0x07,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x70,0x12,0x00,0x00,0x42,0x00,0x00,0x00, + 0x08,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xb4,0x12,0x00,0x00,0x0e,0x00,0x00,0x00, + 0x09,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xc4,0x12,0x00,0x00,0x04,0x00,0x00,0x00, + 0x50,0x4f,0x43,0x4b,0x45,0x54,0x20,0x54,0x45,0x53,0x54,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x04,0x00,0x04,0x00,0x01,0x01, + 0x04,0x00,0x02,0x00,0x01,0x00,0x03,0x00,0x63,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x63,0x10,0x0d,0x33,0x69,0x26,0x0c,0x15,0xe6,0x1d,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0xff,0x7f,0x83,0x24,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x3e,0x53,0x19,0x21,0x85,0x14,0xff,0x7f,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00, + 0x12,0x11,0x21,0x11,0x21,0x11,0x11,0x12,0x11,0x12,0x11,0x21,0x11,0x21,0x11,0x11, + 0x11,0x11,0x12,0x11,0x12,0x11,0x21,0x11,0x21,0x11,0x11,0x12,0x11,0x12,0x11,0x21, + 0x44,0x44,0x44,0x44,0x44,0x44,0x44,0x44,0x44,0x44,0x44,0x44,0x44,0x44,0x44,0x44, + 0x44,0x44,0x44,0x44,0x44,0x34,0x43,0x44,0x44,0x34,0x43,0x44,0x44,0x34,0x43,0x44, + 0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x12,0x11,0x11,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22, + 0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x22,0x12,0x11,0x11,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22, + 0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22,0x22, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x30,0x33,0x00,0x00,0x10,0x11, + 0x00,0x00,0x10,0x11,0x00,0x00,0x10,0x13,0x00,0x00,0x10,0x11,0x00,0x00,0x10,0x11, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x33,0x03,0x00,0x00,0x11,0x01,0x00,0x00, + 0x11,0x01,0x00,0x00,0x31,0x01,0x00,0x00,0x11,0x01,0x00,0x00,0x11,0x01,0x00,0x00, + 0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22, + 0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x00,0x00, + 0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00, + 0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x30,0x33,0x00,0x00,0x10,0x11, + 0x00,0x00,0x10,0x11,0x00,0x00,0x10,0x11,0x00,0x00,0x10,0x11,0x00,0x00,0x10,0x11, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x33,0x03,0x00,0x00,0x11,0x01,0x00,0x00, + 0x11,0x01,0x00,0x00,0x11,0x01,0x00,0x00,0x11,0x01,0x00,0x00,0x11,0x01,0x00,0x00, + 0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22, + 0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x00,0x00, + 0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00, + 0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x30,0x33,0x00,0x00,0x10,0x11, + 0x00,0x00,0x10,0x11,0x00,0x00,0x10,0x13,0x00,0x00,0x10,0x11,0x00,0x00,0x10,0x11, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x33,0x03,0x00,0x00,0x11,0x01,0x00,0x00, + 0x11,0x01,0x00,0x00,0x11,0x01,0x00,0x00,0x11,0x01,0x00,0x00,0x11,0x01,0x00,0x00, + 0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22, + 0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x00,0x00, + 0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00, + 0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x30,0x33,0x00,0x00,0x10,0x11, + 0x00,0x00,0x10,0x11,0x00,0x00,0x10,0x11,0x00,0x00,0x10,0x11,0x00,0x00,0x10,0x11, + 0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x33,0x03,0x00,0x00,0x11,0x01,0x00,0x00, + 0x11,0x01,0x00,0x00,0x31,0x01,0x00,0x00,0x11,0x01,0x00,0x00,0x11,0x01,0x00,0x00, + 0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22, + 0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x00,0x00, + 0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00, + 0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x22,0x22,0x00,0x00,0x00,0x00,0x00,0x00, + 0x00,0x00,0x10,0x10,0x00,0x01,0x00,0x00,0x08,0x00,0x08,0x00,0x01,0x00,0x00,0x00, + 0x00,0xff,0x00,0x00,0x1c,0x00,0x00,0x00,0x9c,0x00,0x00,0x00,0xdc,0x00,0x00,0x00, + 0xe8,0x00,0x00,0x00,0x02,0x00,0x02,0x00,0x02,0x00,0x02,0x00,0x02,0x00,0x02,0x00, + 0x02,0x00,0x02,0x00,0x02,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00, + 0x01,0x00,0x02,0x00,0x02,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00, + 0x01,0x00,0x02,0x00,0x02,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00, + 0x01,0x00,0x02,0x00,0x02,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00, + 0x01,0x00,0x02,0x00,0x02,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00, + 0x01,0x00,0x02,0x00,0x02,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00,0x01,0x00, + 0x01,0x00,0x02,0x00,0x02,0x00,0x02,0x00,0x02,0x00,0x02,0x00,0x02,0x00,0x02,0x00, + 0x02,0x00,0x02,0x00,0x01,0x01,0x01,0x01,0x01,0x01,0x01,0x01,0x01,0x00,0x00,0x00, + 0x00,0x00,0x00,0x01,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x01,0x00,0x00,0x00, + 0x00,0x00,0x00,0x01,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x01,0x00,0x00,0x00, + 0x00,0x00,0x00,0x01,0x01,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x01,0x01,0x01,0x01, + 0x01,0x01,0x01,0x01,0x05,0x00,0x04,0x00,0x00,0x00,0x00,0x01,0x00,0x00,0x00,0x00, + 0x02,0x00,0x00,0x00,0x0c,0x00,0x00,0x00,0x25,0x00,0x00,0x00,0x48,0x45,0x4c,0x4c, + 0x4f,0x20,0x46,0x52,0x4f,0x4d,0x20,0x50,0x4f,0x43,0x4b,0x45,0x54,0x4a,0x53,0x20, + 0x41,0x4f,0x54,0x21,0x00,0x54,0x48,0x49,0x53,0x20,0x52,0x55,0x4e,0x53,0x20,0x41, + 0x53,0x20,0x41,0x20,0x52,0x45,0x41,0x4c,0x20,0x47,0x42,0x41,0x20,0x52,0x4f,0x4d, + 0x2e,0x00,0x00,0x00,0x0f,0x11,0x00,0x02,0x00,0x00,0x03,0x01,0x00,0x02,0x01,0x00, + 0x10,0x00,0x00,0x00,0x00,0x00,0x00,0x00, +}; +const unsigned int pjgb_cart_len = 4808; diff --git a/aot/runtime/nes/crt0.s b/aot/runtime/nes/crt0.s new file mode 100644 index 0000000..5209d99 --- /dev/null +++ b/aot/runtime/nes/crt0.s @@ -0,0 +1,165 @@ +; aot/runtime/nes/crt0.s — NES startup, NMI, and UNROM bank switching for the +; PocketJS-AOT runtime (ca65 syntax, linked by the generated nes.cfg). +; +; The NMI handler owns all PPU traffic while rendering is on: +; 1. OAM DMA from the $0200 shadow page +; 2. flush of the VRAM update buffer (_pj_vbuf): entries [hi,lo,len,data...] +; terminated by hi = $FF. Main-thread code only appends to the buffer +; (RAM), so the NMI never depends on the current ROM bank. +; 3. scroll + PPUCTRL restore, frame flag increment. +; When _pj_ppu_off is nonzero (map reloads), the NMI only bumps the flag. + +.export __STARTUP__ : absolute = 1 +.export _pj_bank_switch +.export _exit +.import _main, zerobss, copydata +.import _pj_vbuf, _pj_nmi_flag, _pj_ppu_off, _pj_ppuctrl +.importzp sp + +PPUCTRL = $2000 +PPUMASK = $2001 +PPUSTATUS = $2002 +PPUSCROLL = $2005 +OAMDMA = $4014 + +.segment "STARTUP" + +reset: + sei + cld + ldx #$40 + stx $4017 ; APU frame IRQ off + ldx #$FF + txs + inx ; x = 0 + stx PPUCTRL ; NMI off + stx PPUMASK ; rendering off + stx $4010 ; DMC IRQ off + bit PPUSTATUS + +@vbl1: + bit PPUSTATUS + bpl @vbl1 + + ; clear all 2 KB of RAM (incl. shadow OAM + debug page) + txa +@clear: + sta $0000,x + sta $0100,x + sta $0200,x + sta $0300,x + sta $0400,x + sta $0500,x + sta $0600,x + sta $0700,x + inx + bne @clear + + ; park sprites offscreen (y = $FF) + lda #$FF +@oam: + sta $0200,x + inx + inx + inx + inx + bne @oam + +@vbl2: + bit PPUSTATUS + bpl @vbl2 + + lda #0 + jsr _pj_bank_switch ; map bank 0 into $8000 + + jsr zerobss + jsr copydata + + ; cc65 software stack: top at $0700 (the debug page sits above) + lda #$00 + sta sp + lda #$07 + sta sp+1 + + jsr _main +_exit: + jmp _exit + +; ------------------------------------------------------------------ NMI ---- +.segment "CODE" + +nmi: + pha + txa + pha + tya + pha + + lda _pj_ppu_off + bne @tick ; rendering off: main thread owns the PPU + + lda #$02 + sta OAMDMA + + ; flush the VRAM update buffer + ldx #0 +@entry: + lda _pj_vbuf,x + cmp #$FF + beq @flushed + sta PPUADDR_HI + inx + lda _pj_vbuf,x + sta PPUADDR_LO + inx + lda _pj_vbuf,x ; len + tay + inx +@data: + lda _pj_vbuf,x + sta $2007 + inx + dey + bne @data + jmp @entry +@flushed: + lda #$FF + sta _pj_vbuf + + ; reset scroll (no scrolling on NES v1) + restore ctrl + bit PPUSTATUS + lda #0 + sta PPUSCROLL + sta PPUSCROLL + lda _pj_ppuctrl + sta PPUCTRL + +@tick: + inc _pj_nmi_flag + + pla + tay + pla + tax + pla +irq: + rti + +; PPUADDR needs hi then lo; give the two stores distinct names for clarity. +PPUADDR_HI = $2006 +PPUADDR_LO = $2006 + +; ------------------------------------------- UNROM bank switch (fastcall a=bank) +_pj_bank_switch: + tax + sta banktable,x ; write value == ROM content: no bus conflict + rts + +.segment "RODATA" +banktable: + .byte 0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 + +.segment "VECTORS" + .word nmi + .word reset + .word irq diff --git a/aot/runtime/nes/main.c b/aot/runtime/nes/main.c new file mode 100644 index 0000000..f4cb121 --- /dev/null +++ b/aot/runtime/nes/main.c @@ -0,0 +1,29 @@ +/* aot/runtime/nes/main.c — entry point + per-frame loop (NES). */ +#include "nesrt.h" + +void main(void) { + video_boot(); + textbox_init(); + g.pending_enter = -1; + map_enter(PJ_START_MAP, PJ_START_X, PJ_START_Y, PJ_START_DIR); /* enables NMI */ + debug_flush(); + + while (1) { + frame_sync(); /* wait for NMI (OAM DMA + vbuf flush done there) */ + + input_poll(); + if (!vm_active() && g.pending_enter >= 0) { + u8 sid = (u8)g.pending_enter; + g.pending_enter = -1; + vm_start(sid, -1); + } + vm_tick(); + if (!vm_active()) player_update(); + textbox_tick(); + choice_tick(); + textbox_pump(); /* append this frame's VRAM work for the next NMI */ + scene_draw(); + debug_flush(); + g.frame++; + } +} diff --git a/aot/runtime/nes/nesrt.c b/aot/runtime/nes/nesrt.c new file mode 100644 index 0000000..dd514de --- /dev/null +++ b/aot/runtime/nes/nesrt.c @@ -0,0 +1,332 @@ +/* aot/runtime/nes/nesrt.c — platform layer: PPU boot, map load, collision, + * player movement, OAM scene, input, VRAM buffer, debug block. */ +#include "nesrt.h" + +PjGame g; +volatile u8 pj_nmi_flag; +volatile u8 pj_ppu_off; +u8 pj_ppuctrl; +u8 pj_vbuf[72]; +static u8 vbuf_len; + +#define PPUCTRL (*(volatile u8 *)0x2000) +#define PPUMASK (*(volatile u8 *)0x2001) +#define PPUSTATUS (*(volatile u8 *)0x2002) +#define PPUADDR (*(volatile u8 *)0x2006) +#define PPUDATA (*(volatile u8 *)0x2007) +#define JOY1 (*(volatile u8 *)0x4016) + +#define OAM ((u8 *)0x0200) + +static const s8 DX[4] = {0, 0, -1, 1}; +static const s8 DY[4] = {1, -1, 0, 0}; + +#define PLAYER_SPEED 2 +#define ANIM_RATE 6 + +/* --- frame sync + VRAM buffer -------------------------------------------- */ +void frame_sync(void) { + u8 f = pj_nmi_flag; + while (f == pj_nmi_flag) {} + /* NMI flushed the buffer and rewrote the terminator */ + vbuf_len = 0; +} + +u8 vbuf_room(u8 payload) { + return (u8)(vbuf_len + 3 + payload + 1 <= sizeof(pj_vbuf)); +} + +static void vbuf_hdr(u16 ppu_addr, u8 len) { + pj_vbuf[vbuf_len++] = (u8)(ppu_addr >> 8); + pj_vbuf[vbuf_len++] = (u8)ppu_addr; + pj_vbuf[vbuf_len++] = len; +} + +void vbuf_copy(u16 ppu_addr, const u8 *src, u8 len) { + u8 i; + if (!vbuf_room(len)) return; + vbuf_hdr(ppu_addr, len); + for (i = 0; i < len; i++) pj_vbuf[vbuf_len++] = src[i]; + pj_vbuf[vbuf_len] = 0xFF; +} + +void vbuf_byte(u16 ppu_addr, u8 v) { + if (!vbuf_room(1)) return; + vbuf_hdr(ppu_addr, 1); + pj_vbuf[vbuf_len++] = v; + pj_vbuf[vbuf_len] = 0xFF; +} + +void vbuf_fill(u16 ppu_addr, u8 v, u8 len) { + u8 i; + if (!vbuf_room(len)) return; + vbuf_hdr(ppu_addr, len); + for (i = 0; i < len; i++) pj_vbuf[vbuf_len++] = v; + pj_vbuf[vbuf_len] = 0xFF; +} + +/* --- raw PPU access (rendering off only) ----------------------------------- */ +static void ppu_addr(u16 a) { + (void)PPUSTATUS; + PPUADDR = (u8)(a >> 8); + PPUADDR = (u8)a; +} + +static void ppu_copy_banked(u16 dst, u8 bank, const u8 *src, u16 len) { + u16 i; + pj_bank_switch(bank); + ppu_addr(dst); + for (i = 0; i < len; i++) PPUDATA = src[i]; +} + +/* --- boot ------------------------------------------------------------------ */ +void video_boot(void) { + u8 i; + pj_ppu_off = 1; + PPUCTRL = 0x00; + PPUMASK = 0x00; + + /* palettes */ + ppu_addr(0x3F00); + for (i = 0; i < 32; i++) PPUDATA = pj_palettes[i]; + + /* CHR-RAM: BG tiles -> PT0, OBJ tiles -> PT1 */ + ppu_copy_banked(0x0000, PJ_BANK_BG_TILES, pj_bg_tiles, (u16)PJ_BG_TILE_COUNT * 16); + ppu_copy_banked(0x1000, PJ_BANK_OBJ_TILES, pj_obj_tiles, (u16)PJ_OBJ_TILE_COUNT * 16); + + pj_ppuctrl = 0xA8; /* NMI on | 8x16 sprites | BG PT0 */ +} + +/* --- map -------------------------------------------------------------------- */ +void map_enter(u8 map_id, u8 tx, u8 ty, u8 dir) { + const PjMapInfo *mi = &pj_maps[map_id]; + u8 cx, cy, i; + + pj_ppu_off = 1; + PPUCTRL = 0x00; /* NMI off while we own the PPU */ + PPUMASK = 0x00; + + g.map_id = map_id; + g.map_w = mi->w; + g.map_h = mi->h; + g.map_coll = mi->coll; + g.map_tiles = mi->tiles; + g.map_tiles_bank = mi->tiles_bank; + g.actors = mi->actors; + g.warps = mi->warps; + g.n_actors = mi->n_actors; + g.n_warps = mi->n_warps; + + /* stale VRAM work targets the old map; drop it (rendering is off) */ + vbuf_len = 0; + pj_vbuf[0] = 0xFF; + for (i = 0; i < g.n_actors; i++) { + g.actor_dir[i] = mi->actors[i].facing; + g.actor_frame[i] = 0; + } + + /* nametable 0: map rows then blank; attributes all palette 0 */ + pj_bank_switch(mi->tiles_bank); + ppu_addr(0x2000); + for (cy = 0; cy < 30; cy++) { + if (cy < g.map_h) { + const u8 *row = mi->tiles + (u16)cy * g.map_w; + for (cx = 0; cx < 32; cx++) PPUDATA = (cx < g.map_w) ? row[cx] : 0; + } else { + for (cx = 0; cx < 32; cx++) PPUDATA = 0; + } + } + ppu_addr(0x23C0); + for (i = 0; i < 64; i++) PPUDATA = 0; + + g.px = (s16)tx * 8; + g.py = (s16)ty * 8; + g.dir = dir; + g.moving = 0; + g.anim_frame = 0; + g.anim_timer = 0; + + g.pending_enter = (mi->on_enter == 0xff) ? -1 : (s8)mi->on_enter; + + /* scroll + rendering back on */ + ppu_addr(0x2000); /* reset address latch usage */ + (void)PPUSTATUS; + *(volatile u8 *)0x2005 = 0; + *(volatile u8 *)0x2005 = 0; + PPUCTRL = pj_ppuctrl; + PPUMASK = 0x1E; /* BG + sprites, no clipping */ + pj_ppu_off = 0; +} + +static const u8 BITMASK[8] = {1, 2, 4, 8, 16, 32, 64, 128}; + +u8 map_solid(s16 tx, s16 ty) { + u16 idx; + u8 i; + if (tx < 0 || ty < 0 || tx >= (s16)g.map_w || ty >= (s16)g.map_h) return 1; + idx = (u16)ty * g.map_w + (u16)tx; + if (g.map_coll[idx >> 3] & BITMASK[idx & 7]) return 1; + for (i = 0; i < g.n_actors; i++) { + if ((g.actors[i].flags & ACTOR_FLAG_SOLID) && (s16)g.actors[i].x == tx && (s16)g.actors[i].y == ty) return 1; + } + return 0; +} + +s8 map_actor_at(s16 tx, s16 ty) { + u8 i; + for (i = 0; i < g.n_actors; i++) { + if ((s16)g.actors[i].x == tx && (s16)g.actors[i].y == ty) return (s8)i; + } + return -1; +} + +/* --- player ------------------------------------------------------------------ */ +void player_update(void) { + if (!g.moving && !g.locked) { + if (key_pressed(PJK_A)) { + s16 tx = g.px >> 3; + s16 ty = g.py >> 3; + s16 fx = tx + DX[g.dir]; + s16 fy = ty + DY[g.dir]; + s8 slot = map_actor_at(fx, fy); + if (slot >= 0 && g.actors[(u8)slot].on_talk != PJGB_SCRIPT_NONE) { + vm_start((u8)g.actors[(u8)slot].on_talk, slot); + return; + } + } + { + s8 dir = -1; + if (key_held(PJK_DOWN)) dir = DIR_DOWN; + else if (key_held(PJK_UP)) dir = DIR_UP; + else if (key_held(PJK_LEFT)) dir = DIR_LEFT; + else if (key_held(PJK_RIGHT)) dir = DIR_RIGHT; + if (dir >= 0) { + s16 nx, ny; + g.dir = (u8)dir; + nx = (g.px >> 3) + DX[g.dir]; + ny = (g.py >> 3) + DY[g.dir]; + if (!map_solid(nx, ny)) g.moving = 1; + } + } + } + + if (g.moving) { + g.px += DX[g.dir] * PLAYER_SPEED; + g.py += DY[g.dir] * PLAYER_SPEED; + ++g.anim_timer; + if (g.anim_timer >= ANIM_RATE) { + g.anim_timer = 0; + g.anim_frame++; + } + if (((g.px & 7) == 0) && ((g.py & 7) == 0)) { + s16 tx = g.px >> 3; + s16 ty = g.py >> 3; + u8 i; + g.moving = 0; + for (i = 0; i < g.n_warps; i++) { + if ((s16)g.warps[i].x == tx && (s16)g.warps[i].y == ty) { + map_enter(g.warps[i].dest_map, (u8)g.warps[i].dest_x, (u8)g.warps[i].dest_y, g.warps[i].dest_dir); + return; + } + } + } + } +} + +/* --- OAM scene ---------------------------------------------------------------- */ +/* 8x16 sprites: OAM tile byte = (tile & 0xFE) | 1 (pattern table 1). + * Hot path: cc65 is slow, so this avoids multiplies (frames is 1 or 2 by + * compiler contract) and parks only the previously used OAM range. */ +static u8 oam_i; +static u8 oam_used_prev; + +static void draw_char(const PjSprite *sp, u8 dir, u8 frame, s16 sx, s16 sy) { + register u8 *o; + u16 tile; + u8 idx, attr, y8, x8; + if (oam_i > 248) return; + if (sx <= -16 || sx >= PJGB_SCREEN_W || sy <= -16 || sy >= PJGB_SCREEN_H) return; + idx = (sp->frames > 1) ? (u8)((dir << 1) | (frame & 1)) : dir; + tile = sp->tile_base + ((u16)idx << 2); + attr = sp->pal & 3; + y8 = (u8)(sy - 1); + x8 = (u8)sx; + o = OAM + oam_i; + o[0] = y8; + o[1] = (u8)(tile & 0xFE) | 1; + o[2] = attr; + o[3] = x8; + o[4] = y8; + o[5] = (u8)((tile + 2) & 0xFE) | 1; + o[6] = attr; + o[7] = (u8)(x8 + 8); + oam_i += 8; +} + +void scene_draw(void) { + register u8 i; + const PjActor *a; + oam_i = 0; + draw_char(&pj_sprites[0], g.dir, g.anim_frame, g.px - 4, g.py - 8); + a = g.actors; + for (i = 0; i < g.n_actors; ++i, ++a) { + if (a->sprite != 0xff) { + draw_char(&pj_sprites[a->sprite], g.actor_dir[i], g.actor_frame[i], + (s16)a->x * 8 - 4, (s16)a->y * 8 - 8); + } + } + /* park slots used last frame but not this one */ + for (i = oam_i; i < oam_used_prev; i += 4) OAM[i] = 0xFF; + oam_used_prev = oam_i; +} + +/* --- input ------------------------------------------------------------------- */ +void input_poll(void) { + u8 i, j, k = 0; + static const u8 MAP[8] = {PJK_A, PJK_B, PJK_SELECT, PJK_START, PJK_UP, PJK_DOWN, PJK_LEFT, PJK_RIGHT}; + JOY1 = 1; + JOY1 = 0; + for (i = 0; i < 8; i++) { + j = JOY1 & 3; + if (j) k |= MAP[i]; + } + g.keys_prev = g.keys; + g.keys = k; +} + +u8 key_held(u8 mask) { return (u8)((g.keys & mask) != 0); } +u8 key_pressed(u8 mask) { return (u8)((g.keys & mask) != 0 && (g.keys_prev & mask) == 0); } + +/* --- debug block ---------------------------------------------------------------- */ +/* Hot path: one flat pointer walk (the block layout matches DBG_* offsets). */ +void debug_flush(void) { + register volatile u8 *d = (volatile u8 *)PJGB_DEBUG_ADDR; + register const u8 *s; + register u8 i; + u16 cur; + d[0x00] = 0x50; /* 'PJDB' */ + d[0x01] = 0x4A; + d[0x02] = 0x44; + d[0x03] = 0x42; + d[0x04] = (u8)(g.px >> 3); /* maps are <= 32 tiles: high bytes stay 0 */ + d[0x05] = 0; + d[0x06] = (u8)(g.py >> 3); + d[0x07] = 0; + d[0x08] = g.dir; + d[0x09] = g.map_id; + d[0x0a] = g.text_active; + d[0x0b] = g.vm.active; + d[0x0c] = (u8)g.frame; + d[0x0d] = (u8)(g.frame >> 8); + d[0x0e] = 0; + d[0x0f] = 0; + cur = g.text_active ? g.cur_text : 0xFFFF; + d[0x10] = (u8)cur; + d[0x11] = (u8)(cur >> 8); + d[0x12] = g.choice_cursor; + d[0x13] = 1; /* BOOTED */ + s = g.flags; + for (i = 0; i < 16; ++i) d[0x14 + i] = s[i]; + s = (const u8 *)g.vars; + for (i = 0; i < 32; ++i) d[0x24 + i] = s[i]; +} diff --git a/aot/runtime/nes/nesrt.h b/aot/runtime/nes/nesrt.h new file mode 100644 index 0000000..c9d8566 --- /dev/null +++ b/aot/runtime/nes/nesrt.h @@ -0,0 +1,142 @@ +/* aot/runtime/nes/nesrt.h — internal contract for the PocketJS-AOT NES + * runtime (cc65, UNROM mapper 2 with 8 KB CHR-RAM). + * + * MEMORY / PPU PLAN: + * CHR-RAM PT0 ($0000): BG tiles — 0 blank, 1.. tileset, PJ_BOX_TILE, then + * PJ_SLOT_BASE.. dynamic glyph slots (2 tiles per halfcell). + * CHR-RAM PT1 ($1000): OBJ tiles (8x16 sprites; tile byte bit0 selects PT1). + * Nametable 0 only; NES v1 does not scroll (maps are <= 32x30). + * BG palette 0 = map tiles (luminance-clustered), palette 1 = textbox. + * + * All PPU traffic while rendering is on goes through the NMI VRAM buffer + * (pj_vbuf); the main thread only appends. Map (re)loads flip pj_ppu_off and + * write the PPU directly with rendering disabled. + * + * Banked data ($8000 window): tile gfx, glyphs, texts, per-map tiles — the + * compiler records each symbol's bank as PJ_BANK_* in gen_data.h. Collision + * (bit-packed), actors, warps, scripts, and all code live in the fixed bank + * ($C000), so the VM and collision paths never switch banks. */ +#ifndef PJ_NESRT_H +#define PJ_NESRT_H + +#include +#include "pjgb_gen.h" +#include "gen_data.h" + +typedef uint8_t u8; +typedef uint16_t u16; +typedef int8_t s8; +typedef int16_t s16; + +/* keys (PJ layout, matches GBA/GB runtimes) */ +#define PJK_A 0x01 +#define PJK_B 0x02 +#define PJK_SELECT 0x04 +#define PJK_START 0x08 +#define PJK_RIGHT 0x10 +#define PJK_LEFT 0x20 +#define PJK_UP 0x40 +#define PJK_DOWN 0x80 + +enum { VM_SUSP_NONE = 0, VM_SUSP_TEXT, VM_SUSP_CHOICE, VM_SUSP_WAIT }; + +typedef struct { + u8 active; + u8 suspend; + const u8 *code; /* fixed-bank pointer */ + u16 ip; + s16 stack[PJGB_VM_MAX_STACK]; + u8 sp; + u16 wait_frames; + s8 actor_slot; +} PjVm; + +typedef struct { + u8 map_id; + u8 map_w, map_h; + const u8 *map_coll; /* fixed bank, bit-packed */ + const u8 *map_tiles; /* banked (map_tiles_bank) — used by textbox restore */ + u8 map_tiles_bank; + const PjActor *actors; /* fixed bank */ + const PjWarp *warps; /* fixed bank */ + u8 n_actors, n_warps; + + s16 px, py; + u8 dir; + u8 moving; + u8 anim_frame, anim_timer; + u8 locked; + + u8 actor_dir[BUDGET_MAX_ACTORS_PER_MAP]; + u8 actor_frame[BUDGET_MAX_ACTORS_PER_MAP]; + + PjVm vm; + s8 pending_enter; + + u8 text_active; + u16 cur_text; + u8 choice_active; + u8 choice_n; + u16 choice_ids[8]; + u8 choice_cursor; + s8 choice_result; + + u8 flags[16]; + s16 vars[16]; + + u16 slot_next; + u16 rng; + + u8 keys, keys_prev; + u16 frame; +} PjGame; + +extern PjGame g; + +/* NMI interface (crt0.s) */ +extern volatile u8 pj_nmi_flag; +extern volatile u8 pj_ppu_off; +extern u8 pj_ppuctrl; +extern u8 pj_vbuf[72]; /* sized so the NMI flush + OAM DMA always fit in vblank; CJK glyphs stream one halfcell per frame */ +void __fastcall__ pj_bank_switch(u8 bank); + +/* nesrt.c */ +void video_boot(void); +void map_enter(u8 map_id, u8 tx, u8 ty, u8 dir); +u8 map_solid(s16 tx, s16 ty); +s8 map_actor_at(s16 tx, s16 ty); +void player_update(void); +void scene_draw(void); +void input_poll(void); +u8 key_held(u8 mask); +u8 key_pressed(u8 mask); +void debug_flush(void); +void frame_sync(void); /* wait for NMI, then reset the vbuf append cursor */ +/* vbuf appends (main thread only) */ +u8 vbuf_room(u8 payload); +void vbuf_copy(u16 ppu_addr, const u8 *src, u8 len); /* src in RAM/fixed */ +void vbuf_byte(u16 ppu_addr, u8 v); +void vbuf_fill(u16 ppu_addr, u8 v, u8 len); + +/* vm.c */ +void vm_start(u8 script_id, s8 actor_slot); +void vm_tick(void); +u8 vm_active(void); + +/* textbox.c */ +void textbox_init(void); +void textbox_show(u16 text_id); +void textbox_hide(void); +u8 textbox_active(void); +void textbox_tick(void); +void textbox_pump(void); /* appends this frame's VRAM work */ +void choice_show(u8 n, const u16 *text_ids); +u8 choice_active(void); +void choice_tick(void); +s8 choice_result(void); + +#define flag_get(id) ((g.flags[(id) >> 3] >> ((id) & 7)) & 1) +#define flag_set1(id) (g.flags[(id) >> 3] |= (u8)(1 << ((id) & 7))) +#define flag_set0(id) (g.flags[(id) >> 3] &= (u8) ~(1 << ((id) & 7))) + +#endif diff --git a/aot/runtime/nes/pjgb_gen.h b/aot/runtime/nes/pjgb_gen.h new file mode 100644 index 0000000..3af792a --- /dev/null +++ b/aot/runtime/nes/pjgb_gen.h @@ -0,0 +1,160 @@ +// GENERATED by aot/spec/gen-c.ts from aot/spec/pjgb.ts (nes) — DO NOT EDIT. +#ifndef PJGB_GEN_H +#define PJGB_GEN_H + +/* ---------------------------------------------------------------------- + * Target + * ---------------------------------------------------------------------- */ + +#define PJGB_SCREEN_W 0x100 +#define PJGB_SCREEN_H 0xf0 +#define PJGB_SCREEN_TILES_W 0x20 +#define PJGB_SCREEN_TILES_H 0x1e +#define PJGB_MAX_MAP_W 0x20 +#define PJGB_MAX_MAP_H 0x1e +#define PJGB_TILE_BYTES 0x10 +#define PJGB_TILE_4BPP_BYTES 0x20 +#define PJGB_TILE_2BPP_BYTES 0x10 + +/* ---------------------------------------------------------------------- + * Text (cjk16 metrics; layout anchors live in the runtime) + * ---------------------------------------------------------------------- */ + +#define PJGB_TEXT_COLS 0x1c +#define PJGB_TEXT_LINES 0x3 +#define PJGB_TEXT_CHOICE_COLS 0x14 +#define PJGB_TEXT_MAX_CHOICES 0x4 +#define PJGB_TEXT_GLYPH_SLOTS 0x54 +#define TEXT_MODE_ASCII8 0x0 +#define TEXT_MODE_CJK16 0x1 +#define TOK_END 0x0 +#define TOK_NEWLINE 0xa +#define TOK_ASCII_MIN 0x20 +#define TOK_ASCII_MAX 0x7e +#define TOK_FULL_FLAG 0x80 +#define PJGB_HALF_GLYPH_COUNT 0x5f +#define PJGB_GLYPH_STORE_HEADER_SIZE 0x8 + +/* ---------------------------------------------------------------------- + * Container header + chunks (GBA parses; GB/NES get gen_data) + * ---------------------------------------------------------------------- */ + +#define PJGB_VERSION 0x1 +#define PJGB_HEADER_SIZE 0x10 +#define PJGB_CHUNK_ENTRY_SIZE 0x10 +#define CHUNK_GAME 0x1 +#define CHUNK_PAL_BG 0x2 +#define CHUNK_PAL_OBJ 0x3 +#define CHUNK_TILES_BG 0x4 +#define CHUNK_TILES_OBJ 0x5 +#define CHUNK_MAP 0x6 +#define CHUNK_TEXT_BANK 0x7 +#define CHUNK_SCRIPT_CODE 0x8 +#define CHUNK_SCRIPT_TABLE 0x9 +#define CHUNK_SPRITE_TABLE 0xa +#define CHUNK_GLYPHS 0xb + +/* ---------------------------------------------------------------------- + * Struct sizes / field constants + * ---------------------------------------------------------------------- */ + +#define PJGB_GAME_TITLE_LEN 0x18 +#define PJGB_GAME_HEADER_SIZE 0x30 +#define PJGB_MAP_HEADER_SIZE 0x1c +#define PJGB_ACTOR_INSTANCE_SIZE 0xc +#define PJGB_WARP_SIZE 0xc +#define PJGB_SPRITE_PROTO_SIZE 0x8 +#define PJGB_SCRIPT_NONE 0xffff +#define PJGB_COLLISION_WALKABLE 0x0 +#define PJGB_COLLISION_SOLID 0x1 + +/* ---------------------------------------------------------------------- + * Directions / movement / actor flags + * ---------------------------------------------------------------------- */ + +#define DIR_DOWN 0x0 +#define DIR_UP 0x1 +#define DIR_LEFT 0x2 +#define DIR_RIGHT 0x3 +#define MOVE_STATIC 0x0 +#define MOVE_WANDER 0x1 +#define MOVE_PATROL_H 0x2 +#define MOVE_PATROL_V 0x3 +#define ACTOR_FLAG_NONE 0x0 +#define ACTOR_FLAG_SOLID 0x1 + +/* ---------------------------------------------------------------------- + * Script VM + * ---------------------------------------------------------------------- */ + +#define OP_END 0x0 +#define OP_NOP 0x1 +#define OP_TEXT 0x2 +#define OP_SET_FLAG 0x3 +#define OP_CLEAR_FLAG 0x4 +#define OP_PUSH_FLAG 0x5 +#define OP_PUSH_CONST 0x6 +#define OP_POP 0x7 +#define OP_DUP 0x8 +#define OP_EQ 0x9 +#define OP_NE 0xa +#define OP_NOT 0xb +#define OP_JUMP 0xc +#define OP_JUMP_IF_FALSE 0xd +#define OP_CHOICE 0xe +#define OP_LOCK_PLAYER 0xf +#define OP_RELEASE_PLAYER 0x10 +#define OP_FACE_PLAYER 0x11 +#define OP_WARP 0x12 +#define OP_SET_VAR 0x13 +#define OP_ADD_VAR 0x14 +#define OP_PUSH_VAR 0x15 +#define OP_GIVE_ITEM 0x16 +#define OP_BATTLE 0x17 +#define OP_WAIT 0x18 +#define OP_PLAY_SFX 0x19 +#define OP_LT 0x1a +#define OP_GT 0x1b +#define OP_LE 0x1c +#define OP_GE 0x1d +#define OP_RND 0x1e +#define PJGB_VM_MAX_STACK 0x10 + +/* ---------------------------------------------------------------------- + * Debug block (fixed RAM address for the emulator harness) + * ---------------------------------------------------------------------- */ + +#define PJGB_DEBUG_ADDR 0x700 +#define PJGB_DEBUG_BLOCK_SIZE 0x44 +#define DEBUG_MAGIC 0x504a4442 +#define DBG_MAGIC 0x0 +#define DBG_PLAYER_X 0x4 +#define DBG_PLAYER_Y 0x6 +#define DBG_PLAYER_DIR 0x8 +#define DBG_CUR_MAP 0x9 +#define DBG_TEXT_ACTIVE 0xa +#define DBG_SCRIPT_ACTIVE 0xb +#define DBG_FRAME 0xc +#define DBG_CUR_TEXT 0x10 +#define DBG_CHOICE_CURSOR 0x12 +#define DBG_BOOTED 0x13 +#define DBG_FLAGS 0x14 +#define DBG_VARS 0x24 + +/* ---------------------------------------------------------------------- + * Budgets + * ---------------------------------------------------------------------- */ + +#define BUDGET_MAX_ACTORS_PER_MAP 0x18 +#define BUDGET_MAX_MAPS 0x20 +#define BUDGET_MAX_SPRITES 0x10 +#define BUDGET_MAX_FLAGS 0x80 +#define BUDGET_MAX_VARS 0x10 +#define BUDGET_MAX_TEXTS 0x200 +#define BUDGET_MAX_SCRIPTS 0xfe +#define BUDGET_MAX_BG_TILES 0x200 +#define BUDGET_MAX_OBJ_TILES 0x400 +#define BUDGET_MAX_MAP_TILES 0x4000 +#define BUDGET_MAX_FULL_GLYPHS 0x200 + +#endif /* PJGB_GEN_H */ diff --git a/aot/runtime/nes/textbox.c b/aot/runtime/nes/textbox.c new file mode 100644 index 0000000..f04cbe0 --- /dev/null +++ b/aot/runtime/nes/textbox.c @@ -0,0 +1,334 @@ +/* aot/runtime/nes/textbox.c — nametable-overlay textbox + choice menu. + * + * The NES has no window layer, so the box is drawn INTO nametable 0 over the + * map, and closing it restores the covered rows from the (banked) map tile + * data. All writes flow through the NMI VRAM buffer; the pump appends as + * much work per frame as the buffer allows, which yields the same typewriter + * reveal as the Game Boy runtime. + * + * Layout: box occupies tile rows [box_row0 .. 29] full-width; text lines are + * 16 px tall starting at box_row0+1, text col 1. Choice rows start at + * box_row0+1 with the cursor in col 1 and option text from col 3. The box + * area uses BG palette 1 (attribute quadrants), the map palette 0. + */ +#include "nesrt.h" + +#define NT0 0x2000 +#define ATTR0 0x23C0 +#define SCREEN_ROWS 30 + +#define TB_TEXT_COL0 1 +#define TB_CHOICE_CURSOR_COL 1 +#define TB_CHOICE_TEXT_COL 3 + +#define MAX_JOBS 5 +#define TOKBUF 160 + +enum { + PH_IDLE = 0, + PH_FILL, + PH_ATTR_ON, + PH_GLYPHS, + PH_SHOWN, + PH_RESTORE, + PH_ATTR_OFF +}; + +typedef struct { + u16 text_id; + u8 row, col; +} TbJob; + +static TbJob jobs[MAX_JOBS]; +static u8 n_jobs, cur_job; +static u8 tokbuf[TOKBUF]; +static u8 tok_pos, tok_loaded; +static u8 cur_row, cur_col; +static u8 box_row0; +static u8 phase; +static u8 work_row; /* fill/restore progress */ +static u8 attr_i; +static u8 cursor_slot_ready; +static u8 cursor_row_prev; +/* right half of a fullwidth glyph awaiting the next frame's vbuf budget */ +static u16 pend_id; +static u8 pend_row, pend_col, pend_on; + +/* --- helpers ----------------------------------------------------------------- */ +static u16 nt_addr(u8 row, u8 col) { return (u16)(NT0 + (u16)row * 32 + col); } + +static void load_tokens(u16 text_id) { + u16 off; + u8 i; + pj_bank_switch(PJ_BANK_TEXTS); + off = pj_text_offs[text_id]; + for (i = 0; i < TOKBUF - 1; i++) { + tokbuf[i] = pj_texts[off + i]; + if (tokbuf[i] == TOK_END) break; + } + tokbuf[TOKBUF - 1] = TOK_END; + tok_pos = 0; + tok_loaded = 1; +} + +/* Upload one halfcell (2 CHR tiles) into slot and point (row,col) at it. + * Returns 0 if the vbuf is full this frame (caller retries next frame). */ +static u8 draw_halfcell(const u8 *src, u8 bank, u8 row, u8 col, u16 slot) { + u8 tile; + if (!vbuf_room(32 + 11)) return 0; /* CHR entry + two 1-byte NT entries */ + tile = (u8)(PJ_SLOT_BASE + slot * 2); + pj_bank_switch(bank); + vbuf_copy((u16)tile * 16, src, 32); + vbuf_byte(nt_addr(row, col), tile); + vbuf_byte(nt_addr(row + 1, col), (u8)(tile + 1)); + return 1; +} + +static u16 alloc_slot(void) { + u16 s = g.slot_next; + if (s * 2 + 2 > (u16)PJGB_TEXT_GLYPH_SLOTS * 2) return 0; + g.slot_next++; + return s; +} + +/* attr bytes overlapping box rows: quadrant fields set to palette 1 */ +static u8 attr_rows_n; +static u8 attr_vals[16]; +static u16 attr_addrs[16]; + +static void attr_plan(u8 on) { + u8 ay; + attr_rows_n = 0; + for (ay = 0; ay < 8; ay++) { + u8 r0 = (u8)(ay * 4); /* attr byte covers tile rows r0..r0+3 */ + u8 v = 0; + if (!on) v = 0; + else { + if (r0 + 0 >= box_row0 && r0 + 0 < SCREEN_ROWS) v |= 0x05; /* top quads */ + if (r0 + 2 >= box_row0 && r0 + 2 < SCREEN_ROWS) v |= 0x50; /* bottom quads */ + if (v == 0) continue; + } + if (on || ((r0 + 3 >= box_row0) && (r0 < SCREEN_ROWS))) { + u8 ax; + for (ax = 0; ax < 8; ax++) { + if (attr_rows_n >= 16) break; + /* one entry per attr row is enough: 8 bytes contiguous */ + (void)ax; + } + attr_addrs[attr_rows_n] = (u16)(ATTR0 + (u16)ay * 8); + attr_vals[attr_rows_n] = v; + attr_rows_n++; + } + } +} + +static void open_box(u8 rows_used) { + box_row0 = (u8)(SCREEN_ROWS - rows_used); + phase = PH_FILL; + work_row = box_row0; + attr_i = 0; + cur_job = 0; + tok_loaded = 0; + pend_on = 0; + g.slot_next = 0; + cursor_slot_ready = 0; + attr_plan(1); +} + +/* --- public API ----------------------------------------------------------------- */ +void textbox_init(void) { + g.text_active = 0; + g.choice_active = 0; + g.choice_result = -1; + phase = PH_IDLE; + n_jobs = 0; +} + +void textbox_show(u16 text_id) { + g.cur_text = text_id; + g.text_active = 1; + g.choice_n = 0; + n_jobs = 1; + jobs[0].text_id = text_id; + jobs[0].row = (u8)(SCREEN_ROWS - (PJGB_TEXT_LINES * 2 + 2) + 1); + jobs[0].col = TB_TEXT_COL0; + open_box((u8)(PJGB_TEXT_LINES * 2 + 2)); +} + +void textbox_hide(void) { + g.text_active = 0; + n_jobs = 0; + phase = PH_RESTORE; + work_row = box_row0; + attr_i = 0; +} + +u8 textbox_active(void) { return g.text_active; } + +void textbox_tick(void) { + if (g.text_active && !g.choice_active && key_pressed(PJK_A)) textbox_hide(); +} + +void choice_show(u8 n, const u16 *text_ids) { + u8 i; + g.choice_active = 1; + g.choice_n = n; + g.choice_cursor = 0; + g.choice_result = -1; + for (i = 0; i < n && i < 8; i++) g.choice_ids[i] = text_ids[i]; + g.text_active = 1; + n_jobs = n; + open_box((u8)(n * 2 + 2)); + for (i = 0; i < n; i++) { + jobs[i].text_id = g.choice_ids[i]; + jobs[i].row = (u8)(box_row0 + 1 + i * 2); + jobs[i].col = TB_CHOICE_TEXT_COL; + } + g.slot_next = 1; /* slot 0 = cursor glyph */ + cursor_row_prev = (u8)(box_row0 + 1); +} + +u8 choice_active(void) { return g.choice_active; } +s8 choice_result(void) { return g.choice_result; } + +void choice_tick(void) { + if (!g.choice_active) return; + if (key_pressed(PJK_UP) && g.choice_cursor > 0) g.choice_cursor--; + else if (key_pressed(PJK_DOWN) && g.choice_cursor < g.choice_n - 1) g.choice_cursor++; + if (key_pressed(PJK_A)) { + g.choice_result = (s8)g.choice_cursor; + g.choice_active = 0; + textbox_hide(); + } +} + +/* --- token streaming --------------------------------------------------------------- + * The vbuf is deliberately small (NMI budget), so a fullwidth glyph streams + * as two halfcells that may land on consecutive frames (pend_*). */ +static u8 pump_token(void) { + u8 tok; + if (pend_on) { + if (!draw_halfcell(pj_glyphs_full + (pend_id << 6) + 32, PJ_BANK_GLYPHS_FULL, pend_row, pend_col, alloc_slot())) + return 2; + pend_on = 0; + return 1; + } + if (!tok_loaded) return 0; + tok = tokbuf[tok_pos]; + if (tok == TOK_END) return 0; + if (tok == TOK_NEWLINE) { + tok_pos++; + cur_row += 2; + cur_col = jobs[cur_job].col; + return 1; + } + if (tok & TOK_FULL_FLAG) { + u16 id = (((u16)(tok & 0x3f)) << 8) | tokbuf[tok_pos + 1]; + if (!draw_halfcell(pj_glyphs_full + (id << 6), PJ_BANK_GLYPHS_FULL, cur_row, cur_col, alloc_slot())) + return 2; + pend_id = id; + pend_row = cur_row; + pend_col = (u8)(cur_col + 1); + pend_on = 1; + tok_pos += 2; + cur_col += 2; + } else { + if (!draw_halfcell(pj_glyphs_half + (u16)(tok - TOK_ASCII_MIN) * 32, PJ_BANK_GLYPHS_HALF, cur_row, cur_col, alloc_slot())) + return 2; + tok_pos++; + cur_col += 1; + } + return 1; +} + +/* --- the per-frame pump --------------------------------------------------------------- */ +void textbox_pump(void) { + u8 r; + switch (phase) { + case PH_IDLE: + case PH_SHOWN: + break; + + case PH_FILL: + while (work_row < SCREEN_ROWS && vbuf_room(32)) { + vbuf_fill(nt_addr(work_row, 0), PJ_BOX_TILE, 32); + work_row++; + } + if (work_row >= SCREEN_ROWS) phase = PH_ATTR_ON; + break; + + case PH_ATTR_ON: + while (attr_i < attr_rows_n && vbuf_room(8)) { + vbuf_fill(attr_addrs[attr_i], attr_vals[attr_i], 8); + attr_i++; + } + if (attr_i >= attr_rows_n) phase = PH_GLYPHS; + break; + + case PH_GLYPHS: + /* choice cursor first (slot 0), then option/text glyph streaming */ + if (g.choice_active && !cursor_slot_ready) { + if (!draw_halfcell(pj_glyphs_half + (u16)('>' - TOK_ASCII_MIN) * 32, PJ_BANK_GLYPHS_HALF, + (u8)(box_row0 + 1 + g.choice_cursor * 2), TB_CHOICE_CURSOR_COL, 0)) + return; + cursor_slot_ready = 1; + cursor_row_prev = (u8)(box_row0 + 1 + g.choice_cursor * 2); + } + for (;;) { + if (!tok_loaded) { + if (cur_job >= n_jobs) { + phase = PH_SHOWN; + break; + } + load_tokens(jobs[cur_job].text_id); + cur_row = jobs[cur_job].row; + cur_col = jobs[cur_job].col; + } + r = pump_token(); + if (r == 2) break; /* vbuf full this frame */ + if (r == 0) { + tok_loaded = 0; + cur_job++; + } + } + break; + + case PH_RESTORE: + while (work_row < SCREEN_ROWS && vbuf_room(32)) { + if (work_row < g.map_h) { + pj_bank_switch(g.map_tiles_bank); + vbuf_copy(nt_addr(work_row, 0), g.map_tiles + (u16)work_row * g.map_w, g.map_w); + if (g.map_w < 32) vbuf_fill(nt_addr(work_row, g.map_w), 0, (u8)(32 - g.map_w)); + } else { + vbuf_fill(nt_addr(work_row, 0), 0, 32); + } + work_row++; + } + if (work_row >= SCREEN_ROWS) { + attr_plan(1); /* reuse addr list; values overwritten below */ + attr_i = 0; + phase = PH_ATTR_OFF; + } + break; + + case PH_ATTR_OFF: + while (attr_i < attr_rows_n && vbuf_room(8)) { + vbuf_fill(attr_addrs[attr_i], 0, 8); + attr_i++; + } + if (attr_i >= attr_rows_n) phase = PH_IDLE; + break; + } + + /* cursor tracking while the menu is up */ + if (g.choice_active && cursor_slot_ready) { + u8 row = (u8)(box_row0 + 1 + g.choice_cursor * 2); + if (row != cursor_row_prev && vbuf_room(8)) { + vbuf_byte(nt_addr(cursor_row_prev, TB_CHOICE_CURSOR_COL), PJ_BOX_TILE); + vbuf_byte(nt_addr(cursor_row_prev + 1, TB_CHOICE_CURSOR_COL), PJ_BOX_TILE); + vbuf_byte(nt_addr(row, TB_CHOICE_CURSOR_COL), PJ_SLOT_BASE); + vbuf_byte(nt_addr(row + 1, TB_CHOICE_CURSOR_COL), PJ_SLOT_BASE + 1); + cursor_row_prev = row; + } + } +} diff --git a/aot/runtime/nes/vm.c b/aot/runtime/nes/vm.c new file mode 100644 index 0000000..2e31c6a --- /dev/null +++ b/aot/runtime/nes/vm.c @@ -0,0 +1,247 @@ +/* aot/runtime/nes/vm.c — the event-script stack machine (NES port). + * + * Script bytecode lives in the FIXED bank, so the interpreter reads it + * through a plain pointer with no bank juggling. Semantics mirror + * runtime/gba/script_vm.c op for op (the cross-target E2E suite drives + * identical scenarios on every platform). */ +#include "nesrt.h" + +static u8 rd_u8(void) { return g.vm.code[g.vm.ip++]; } +static u16 rd_u16(void) { + u16 v = (u16)g.vm.code[g.vm.ip] | ((u16)g.vm.code[g.vm.ip + 1] << 8); + g.vm.ip += 2; + return v; +} +static s16 rd_i16(void) { return (s16)rd_u16(); } + +static void push(s16 v) { + if (g.vm.sp < PJGB_VM_MAX_STACK) g.vm.stack[g.vm.sp++] = v; +} +static s16 pop(void) { + if (g.vm.sp > 0) return g.vm.stack[--g.vm.sp]; + return 0; +} + +static void face_player(s8 slot) { + s16 ax, ay, px, py, dx, dy, adx, ady; + u8 dir; + if (slot < 0 || slot >= (s8)g.n_actors) return; + ax = (s16)g.actors[(u8)slot].x; + ay = (s16)g.actors[(u8)slot].y; + px = g.px >> 3; + py = g.py >> 3; + dx = px - ax; + dy = py - ay; + adx = dx < 0 ? -dx : dx; + ady = dy < 0 ? -dy : dy; + if (adx >= ady) dir = dx > 0 ? DIR_RIGHT : DIR_LEFT; + else dir = dy > 0 ? DIR_DOWN : DIR_UP; + g.actor_dir[(u8)slot] = dir; +} + +void vm_start(u8 script_id, s8 actor_slot) { + g.vm.code = pj_scripts + pj_script_offs[script_id]; + g.vm.ip = 0; + g.vm.sp = 0; + g.vm.active = 1; + g.vm.suspend = VM_SUSP_NONE; + g.vm.wait_frames = 0; + g.vm.actor_slot = actor_slot; +} + +u8 vm_active(void) { return g.vm.active; } + +void vm_tick(void) { + if (!g.vm.active) return; + + switch (g.vm.suspend) { + case VM_SUSP_WAIT: + if (--g.vm.wait_frames == 0) g.vm.suspend = VM_SUSP_NONE; + else return; + break; + case VM_SUSP_TEXT: + if (!textbox_active()) g.vm.suspend = VM_SUSP_NONE; + else return; + break; + case VM_SUSP_CHOICE: + if (choice_result() >= 0) { + push((s16)choice_result()); + g.vm.suspend = VM_SUSP_NONE; + } else { + return; + } + break; + default: + break; + } + + for (;;) { + u8 op = rd_u8(); + switch (op) { + case OP_END: + g.vm.active = 0; + return; + case OP_NOP: + break; + case OP_TEXT: { + u16 t = rd_u16(); + textbox_show(t); + g.vm.suspend = VM_SUSP_TEXT; + return; + } + case OP_SET_FLAG: { + u16 f = rd_u16(); + flag_set1(f); + break; + } + case OP_CLEAR_FLAG: { + u16 f = rd_u16(); + flag_set0(f); + break; + } + case OP_PUSH_FLAG: { + u16 f = rd_u16(); + push((s16)flag_get(f)); + break; + } + case OP_PUSH_CONST: + push(rd_i16()); + break; + case OP_POP: + pop(); + break; + case OP_DUP: { + s16 v = pop(); + push(v); + push(v); + break; + } + case OP_EQ: { + s16 b = pop(), a = pop(); + push(a == b ? 1 : 0); + break; + } + case OP_NE: { + s16 b = pop(), a = pop(); + push(a != b ? 1 : 0); + break; + } + case OP_NOT: { + s16 a = pop(); + push(a ? 0 : 1); + break; + } + case OP_JUMP: { + s16 rel = rd_i16(); + g.vm.ip = (u16)((s16)g.vm.ip + rel); + break; + } + case OP_JUMP_IF_FALSE: { + s16 rel = rd_i16(); + if (!pop()) g.vm.ip = (u16)((s16)g.vm.ip + rel); + break; + } + case OP_CHOICE: { + u8 n = rd_u8(); + u16 ids[8]; + u8 i; + for (i = 0; i < n; i++) { + u16 id = rd_u16(); + if (i < 8) ids[i] = id; + } + if (n > 8) n = 8; + choice_show(n, ids); + g.vm.suspend = VM_SUSP_CHOICE; + return; + } + case OP_LOCK_PLAYER: + g.locked = 1; + break; + case OP_RELEASE_PLAYER: + g.locked = 0; + break; + case OP_FACE_PLAYER: { + u8 slot = rd_u8(); + if (slot == 0xff) { + if (g.vm.actor_slot < 0) break; + slot = (u8)g.vm.actor_slot; + } + face_player((s8)slot); + break; + } + case OP_WARP: { + u8 m = rd_u8(); + u16 x = rd_u16(); + u16 y = rd_u16(); + u8 d = rd_u8(); + map_enter(m, (u8)x, (u8)y, d); + break; + } + case OP_SET_VAR: { + u16 i = rd_u16(); + s16 v = rd_i16(); + if (i < BUDGET_MAX_VARS) g.vars[i] = v; + break; + } + case OP_ADD_VAR: { + u16 i = rd_u16(); + s16 v = rd_i16(); + if (i < BUDGET_MAX_VARS) g.vars[i] += v; + break; + } + case OP_PUSH_VAR: { + u16 i = rd_u16(); + push(i < BUDGET_MAX_VARS ? g.vars[i] : 0); + break; + } + case OP_GIVE_ITEM: + rd_u16(); + rd_u8(); /* stub (parity with GBA) */ + break; + case OP_BATTLE: + rd_u16(); + push(1); /* stub: "won" */ + break; + case OP_WAIT: { + u16 n = rd_u16(); + if (n == 0) break; + g.vm.wait_frames = n; + g.vm.suspend = VM_SUSP_WAIT; + return; + } + case OP_PLAY_SFX: + rd_u16(); + break; + case OP_LT: { + s16 b = pop(), a = pop(); + push(a < b ? 1 : 0); + break; + } + case OP_GT: { + s16 b = pop(), a = pop(); + push(a > b ? 1 : 0); + break; + } + case OP_LE: { + s16 b = pop(), a = pop(); + push(a <= b ? 1 : 0); + break; + } + case OP_GE: { + s16 b = pop(), a = pop(); + push(a >= b ? 1 : 0); + break; + } + case OP_RND: { + u8 n = rd_u8(); + if (g.rng == 0) g.rng = g.frame | 1; + g.rng = g.rng * 25173u + 13849u; + push(n ? (s16)((g.rng >> 4) % n) : 0); + break; + } + default: + g.vm.active = 0; + return; + } + } +} diff --git a/aot/runtime/textbox.c b/aot/runtime/textbox.c deleted file mode 100644 index fb0e1c3..0000000 --- a/aot/runtime/textbox.c +++ /dev/null @@ -1,116 +0,0 @@ -// aot/runtime/textbox.c — BG1 textbox + choice menu (screenblock PJ_TEXT_SBB). -#include "runtime.h" - -#define BOX_ROW0 12 -#define BOX_ROW1 19 -#define TEXT_COL0 1 -#define TEXT_ROW0 13 -#define TEXT_COLMAX 28 - -const char *text_get(int text_id) { - const u8 *chunk = cart_chunk(CHUNK_TEXT_BANK, 0, 0); - // u16 count, u16 rsv, u32 offsets[count] (from chunk start), then strings. - const u32 *offs = (const u32 *)(chunk + 4); - return (const char *)(chunk + offs[text_id]); -} - -static void box_fill(void) { - u16 *sb = SCREENBLOCK(PJ_TEXT_SBB); - for (int row = BOX_ROW0; row <= BOX_ROW1; row++) - for (int col = 0; col < 30; col++) - sb[row * 32 + col] = SE(g.game->box_tile, 15); -} - -static void put_char(u16 *sb, int row, int col, unsigned char c) { - if (c >= 0x20) sb[row * 32 + col] = SE(g.game->font_base + (c - 0x20), 15); -} - -void textbox_init(void) { - g.text_active = 0; - g.choice_active = 0; - g.choice_result = -1; -} - -void textbox_show(int text_id) { - g.cur_text = (u16)text_id; - g.text_active = 1; - - u16 *sb = SCREENBLOCK(PJ_TEXT_SBB); - box_fill(); - - const char *t = text_get(text_id); - int col = TEXT_COL0, row = TEXT_ROW0; - for (const char *c = t; *c; c++) { - if (*c == '\n') { - row++; - col = TEXT_COL0; - if (row > BOX_ROW1) break; - continue; - } - if (col > TEXT_COLMAX) { - row++; - col = TEXT_COL0; - } - if (row > BOX_ROW1) break; - put_char(sb, row, col, (unsigned char)*c); - col++; - } - - REG_DISPCNT |= DCNT_BG1; -} - -void textbox_hide(void) { - g.text_active = 0; - REG_DISPCNT &= ~DCNT_BG1; -} - -int textbox_active(void) { return g.text_active; } - -void textbox_tick(void) { - if (g.text_active && !g.choice_active && key_pressed(KEY_A)) textbox_hide(); -} - -// --- choice menu ----------------------------------------------------------- -static void choice_render(void) { - u16 *sb = SCREENBLOCK(PJ_TEXT_SBB); - box_fill(); - for (int i = 0; i < g.choice_n; i++) { - int row = TEXT_ROW0 + i; - if (row > BOX_ROW1) break; - if (i == g.choice_cursor) put_char(sb, row, TEXT_COL0, '>'); - const char *t = text_get(g.choice_ids[i]); - int col = TEXT_COL0 + 2; - for (const char *c = t; *c && col <= TEXT_COLMAX; c++, col++) - put_char(sb, row, col, (unsigned char)*c); - } -} - -void choice_show(int n, const u16 *text_ids) { - g.choice_active = 1; - g.choice_n = (u8)n; - g.choice_cursor = 0; - g.choice_result = -1; - for (int i = 0; i < n && i < 8; i++) g.choice_ids[i] = text_ids[i]; - choice_render(); - REG_DISPCNT |= DCNT_BG1; -} - -int choice_active(void) { return g.choice_active; } - -int choice_result(void) { return g.choice_result; } - -void choice_tick(void) { - if (!g.choice_active) return; - if (key_pressed(KEY_UP) && g.choice_cursor > 0) { - g.choice_cursor--; - choice_render(); - } else if (key_pressed(KEY_DOWN) && g.choice_cursor < g.choice_n - 1) { - g.choice_cursor++; - choice_render(); - } - if (key_pressed(KEY_A)) { - g.choice_result = g.choice_cursor; - g.choice_active = 0; - textbox_hide(); - } -} diff --git a/aot/spec/gen-c.ts b/aot/spec/gen-c.ts index 7219d9a..a75e795 100644 --- a/aot/spec/gen-c.ts +++ b/aot/spec/gen-c.ts @@ -1,7 +1,7 @@ -// aot/spec/gen-c.ts — generate runtime/pjgb_gen.h from spec/pjgb.ts so the C -// runtime can never drift from the TS compiler. Mirrors ../../spec/gen-rust.ts. +// aot/spec/gen-c.ts — generate runtime//pjgb_gen.h from spec/pjgb.ts +// so no C runtime can drift from the TS compiler. Mirrors ../../spec/gen-rust.ts. // -// bun aot/spec/gen-c.ts (writes aot/runtime/pjgb_gen.h) +// bun aot/spec/gen-c.ts (writes aot/runtime/{gba,gb,nes}/pjgb_gen.h) import { ACTOR_FLAG, @@ -11,28 +11,33 @@ import { COLLISION_SOLID, COLLISION_WALKABLE, DBG, - DEBUG_ADDR, DEBUG_BLOCK_SIZE, DEBUG_MAGIC, DIR, - EWRAM_BASE, GAME_HEADER_SIZE, GAME_TITLE_LEN, + GLYPH_STORE_HEADER_SIZE, + HALF_GLYPH_COUNT, MAP_HEADER_SIZE, MOVE, OP, PJGB_CHUNK_ENTRY_SIZE, PJGB_HEADER_SIZE, PJGB_VERSION, - SCREEN_H, - SCREEN_TILES_H, - SCREEN_TILES_W, - SCREEN_W, SCRIPT_NONE, SPRITE_PROTO_SIZE, + TARGETS, + TEXT_MODE, + TILE_2BPP_BYTES, TILE_4BPP_BYTES, + TOK_ASCII_MAX, + TOK_ASCII_MIN, + TOK_END, + TOK_FULL_FLAG, + TOK_NEWLINE, VM_MAX_STACK, WARP_SIZE, + type TargetName, } from "./pjgb.ts"; function section(title: string): string { @@ -44,61 +49,88 @@ function defs(prefix: string, obj: Record): string { .join("\n"); } -const lines: string[] = []; -lines.push("// GENERATED by aot/spec/gen-c.ts from aot/spec/pjgb.ts — DO NOT EDIT."); -lines.push("#ifndef PJGB_GEN_H"); -lines.push("#define PJGB_GEN_H"); -lines.push("#include "); +function header(target: TargetName): string { + const t = TARGETS[target]; + const lines: string[] = []; + lines.push(`// GENERATED by aot/spec/gen-c.ts from aot/spec/pjgb.ts (${target}) — DO NOT EDIT.`); + lines.push("#ifndef PJGB_GEN_H"); + lines.push("#define PJGB_GEN_H"); -lines.push(section("Screen / tiles")); -lines.push(defs("PJGB_", { - SCREEN_W, - SCREEN_H, - SCREEN_TILES_W, - SCREEN_TILES_H, - TILE_4BPP_BYTES, -})); + lines.push(section("Target")); + lines.push(defs("PJGB_", { + SCREEN_W: t.screenW, + SCREEN_H: t.screenH, + SCREEN_TILES_W: t.screenW / 8, + SCREEN_TILES_H: t.screenH / 8, + MAX_MAP_W: t.maxMapW, + MAX_MAP_H: t.maxMapH, + TILE_BYTES: t.tileBytes, + TILE_4BPP_BYTES, + TILE_2BPP_BYTES, + })); -lines.push(section("Container header + chunks")); -lines.push(defs("PJGB_", { - VERSION: PJGB_VERSION, - HEADER_SIZE: PJGB_HEADER_SIZE, - CHUNK_ENTRY_SIZE: PJGB_CHUNK_ENTRY_SIZE, -})); -lines.push(defs("CHUNK_", CHUNK)); + lines.push(section("Text (cjk16 metrics; layout anchors live in the runtime)")); + lines.push(defs("PJGB_TEXT_", { + COLS: t.textCols, + LINES: t.textLines, + CHOICE_COLS: t.choiceCols, + MAX_CHOICES: t.maxChoices, + GLYPH_SLOTS: t.glyphSlots, + })); + lines.push(defs("TEXT_MODE_", TEXT_MODE)); + lines.push(defs("TOK_", { + END: TOK_END, + NEWLINE: TOK_NEWLINE, + ASCII_MIN: TOK_ASCII_MIN, + ASCII_MAX: TOK_ASCII_MAX, + FULL_FLAG: TOK_FULL_FLAG, + })); + lines.push(defs("PJGB_", { HALF_GLYPH_COUNT, GLYPH_STORE_HEADER_SIZE })); -lines.push(section("Struct sizes / field constants")); -lines.push(defs("PJGB_", { - GAME_TITLE_LEN, - GAME_HEADER_SIZE, - MAP_HEADER_SIZE, - ACTOR_INSTANCE_SIZE, - WARP_SIZE, - SPRITE_PROTO_SIZE, - SCRIPT_NONE, - COLLISION_WALKABLE, - COLLISION_SOLID, -})); + lines.push(section("Container header + chunks (GBA parses; GB/NES get gen_data)")); + lines.push(defs("PJGB_", { + VERSION: PJGB_VERSION, + HEADER_SIZE: PJGB_HEADER_SIZE, + CHUNK_ENTRY_SIZE: PJGB_CHUNK_ENTRY_SIZE, + })); + lines.push(defs("CHUNK_", CHUNK)); -lines.push(section("Directions / movement / actor flags")); -lines.push(defs("DIR_", DIR)); -lines.push(defs("MOVE_", MOVE)); -lines.push(defs("ACTOR_FLAG_", ACTOR_FLAG)); + lines.push(section("Struct sizes / field constants")); + lines.push(defs("PJGB_", { + GAME_TITLE_LEN, + GAME_HEADER_SIZE, + MAP_HEADER_SIZE, + ACTOR_INSTANCE_SIZE, + WARP_SIZE, + SPRITE_PROTO_SIZE, + SCRIPT_NONE, + COLLISION_WALKABLE, + COLLISION_SOLID, + })); -lines.push(section("Script VM")); -lines.push(defs("OP_", OP)); -lines.push(defs("PJGB_", { VM_MAX_STACK })); + lines.push(section("Directions / movement / actor flags")); + lines.push(defs("DIR_", DIR)); + lines.push(defs("MOVE_", MOVE)); + lines.push(defs("ACTOR_FLAG_", ACTOR_FLAG)); -lines.push(section("Debug block (fixed EWRAM address for the mGBA harness)")); -lines.push(defs("PJGB_", { EWRAM_BASE, DEBUG_ADDR, DEBUG_BLOCK_SIZE })); -lines.push(`#define DEBUG_MAGIC 0x${(DEBUG_MAGIC >>> 0).toString(16)}`); -lines.push(defs("DBG_", DBG)); + lines.push(section("Script VM")); + lines.push(defs("OP_", OP)); + lines.push(defs("PJGB_", { VM_MAX_STACK })); -lines.push(section("Budgets")); -lines.push(defs("BUDGET_", BUDGET)); + lines.push(section("Debug block (fixed RAM address for the emulator harness)")); + lines.push(defs("PJGB_", { DEBUG_ADDR: t.debugAddr, DEBUG_BLOCK_SIZE })); + lines.push(`#define DEBUG_MAGIC 0x${(DEBUG_MAGIC >>> 0).toString(16)}`); + lines.push(defs("DBG_", DBG)); -lines.push("\n#endif /* PJGB_GEN_H */\n"); + lines.push(section("Budgets")); + lines.push(defs("BUDGET_", BUDGET)); -const out = new URL("../runtime/pjgb_gen.h", import.meta.url).pathname; -await Bun.write(out, lines.join("\n")); -console.log("wrote", out); + lines.push("\n#endif /* PJGB_GEN_H */\n"); + return lines.join("\n"); +} + +for (const target of ["gba", "gb", "nes"] as const) { + const out = new URL(`../runtime/${target}/pjgb_gen.h`, import.meta.url).pathname; + await Bun.write(out, header(target)); + console.log("wrote", out); +} diff --git a/aot/spec/pjgb.ts b/aot/spec/pjgb.ts index da4abc0..559bc47 100644 --- a/aot/spec/pjgb.ts +++ b/aot/spec/pjgb.ts @@ -1,25 +1,110 @@ -// aot/spec/pjgb.ts — THE single source of truth for the PJGB cartridge format, -// the script bytecode ISA, and the runtime debug block. +// aot/spec/pjgb.ts — THE single source of truth for the PJGB game data model, +// the script bytecode ISA, the text/glyph encoding, and the runtime debug +// block — shared by ALL cartridge targets (GBA, Game Boy, NES). // // Both sides derive from this file: // - the compiler (aot/compiler/*) ENCODES these layouts, -// - the C runtime (aot/runtime/*) DECODES them, via generated pjgb_gen.h -// (aot/spec/gen-c.ts emits the #defines so C can never drift from TS). +// - each C runtime (aot/runtime//*) DECODES them, via a generated +// pjgb_gen.h (aot/spec/gen-c.ts emits per-target #defines so C can never +// drift from TS). +// +// Target split: +// - The GBA target ships the PJGB chunk container below verbatim and parses +// it at boot (flat 32 MB ROM space makes that free). +// - The GB/NES targets do NOT parse a container: the compiler residualizes +// the same logical records into per-bank C arrays (gen_data.c) because +// banked 8-bit ROMs have no flat address space. The RECORD layouts +// (actors, warps, text tokens, script bytecode, debug block) stay +// byte-identical across targets; only the packaging differs. // // Conventions (non-negotiable, matches the repo-wide rule in ../../spec/spec.ts): -// - Little-endian EVERYWHERE (GBA ARM7TDMI is LE). +// - Little-endian EVERYWHERE (all three CPUs are LE). // - All offsets in comments are from the start of the containing blob/chunk. // - GBA colors are 15-bit BGR555: bit0-4 R, 5-9 G, 10-14 B, bit15 unused. // --------------------------------------------------------------------------- -// Screen / hardware +// Screen / hardware (GBA values; per-target values live in TARGETS below) // --------------------------------------------------------------------------- export const SCREEN_W = 240; export const SCREEN_H = 160; -export const TILE_PX = 8; // one GBA tile is 8x8 px +export const TILE_PX = 8; // one BG tile is 8x8 px on every target export const SCREEN_TILES_W = SCREEN_W / TILE_PX; // 30 export const SCREEN_TILES_H = SCREEN_H / TILE_PX; // 20 -export const TILE_4BPP_BYTES = 32; // 8x8 @ 4bpp +export const TILE_4BPP_BYTES = 32; // 8x8 @ 4bpp (GBA) +export const TILE_2BPP_BYTES = 16; // 8x8 @ 2bpp (GB interleaved / NES planar) + +// --------------------------------------------------------------------------- +// Targets. One authored game compiles to any of these; the compiler wraps +// text and sizes VRAM slot regions per target, so these numbers are part of +// the binary contract (they shape text banks and glyph slot ids). +// --------------------------------------------------------------------------- +export interface TargetSpec { + name: "gba" | "gb" | "nes"; + screenW: number; + screenH: number; + /** Max map size in tiles (w, h). NES is bounded by a single nametable. */ + maxMapW: number; + maxMapH: number; + /** Bytes per 8x8 tile in this target's native format. */ + tileBytes: number; + /** Text metrics for cjk16 mode: halfcell columns and 16px lines per page. */ + textCols: number; + textLines: number; + /** Halfcell columns available for a choice row (incl. 2-cell cursor). */ + choiceCols: number; + /** Max choice options per menu. */ + maxChoices: number; + /** Dynamic glyph slot budget (1 slot = 1 halfcell = 2 stacked 8x8 tiles). */ + glyphSlots: number; + /** Absolute bus address of the debug block. */ + debugAddr: number; +} + +export const TARGETS: Record<"gba" | "gb" | "nes", TargetSpec> = { + gba: { + name: "gba", + screenW: 240, + screenH: 160, + maxMapW: 32, + maxMapH: 32, + tileBytes: TILE_4BPP_BYTES, + textCols: 28, + textLines: 3, + choiceCols: 20, + maxChoices: 4, + glyphSlots: 84, // 28 cols x 3 lines + debugAddr: 0x02000000, // EWRAM base + }, + gb: { + name: "gb", + screenW: 160, + screenH: 144, + maxMapW: 32, + maxMapH: 32, + tileBytes: TILE_2BPP_BYTES, + textCols: 18, + textLines: 2, + choiceCols: 18, + maxChoices: 4, + glyphSlots: 72, // max(18x2 text, 18x4 choices) + debugAddr: 0xde00, // top of DMG WRAM, below the GBDK stack + }, + nes: { + name: "nes", + screenW: 256, + screenH: 240, + maxMapW: 32, + maxMapH: 30, // one nametable; v1 NES does not scroll + tileBytes: TILE_2BPP_BYTES, + textCols: 28, + textLines: 3, + choiceCols: 20, + maxChoices: 4, + glyphSlots: 84, // max(28x3 text, 20x4 choices) + debugAddr: 0x0700, // top page of the 2 KB CPU RAM + }, +} as const; +export type TargetName = keyof typeof TARGETS; // --------------------------------------------------------------------------- // Cartridge container: "PJGB" @@ -54,6 +139,7 @@ export const CHUNK = { SCRIPT_CODE: 8, // raw bytecode bytes for all scripts, concatenated SCRIPT_TABLE: 9, // u32[] byte-offsets into SCRIPT_CODE, indexed by script id SPRITE_TABLE: 10, // SpriteProto[] indexed by sprite id + GLYPHS: 11, // cjk16 glyph tile store (GlyphStore, target-encoded tiles) } as const; export type ChunkKind = (typeof CHUNK)[keyof typeof CHUNK]; @@ -69,13 +155,48 @@ export type ChunkKind = (typeof CHUNK)[keyof typeof CHUNK]; // 32 u16 flag_count // 34 u16 text_count // 36 u16 script_count -// 38 u16 font_base (BG tile index of ASCII 0x20; glyph = font_base+ch-0x20) +// 38 u16 font_base (ascii8: BG tile index of ASCII 0x20; cjk16: 0) // 40 u16 box_tile (BG tile index of the opaque textbox fill tile) -// 42 u16 reserved -// = 44 bytes +// 42 u8 text_mode (TEXT_MODE.*) +// 43 u8 reserved +// 44 u16 glyph_slot_base (cjk16: first BG tile of the dynamic slot region) +// 46 u16 glyph_slot_count (cjk16: slot region size in 8x8 tiles) +// = 48 bytes // --------------------------------------------------------------------------- export const GAME_TITLE_LEN = 24; -export const GAME_HEADER_SIZE = 44; +export const GAME_HEADER_SIZE = 48; + +export const TEXT_MODE = { + ASCII8: 0, // legacy 8x8 ASCII font baked as static BG tiles (GBA only) + CJK16: 1, // 16px lines; glyphs streamed into VRAM slots on demand +} as const; + +// --------------------------------------------------------------------------- +// Text token stream (cjk16 mode). A "halfcell" is an 8px-wide, 16px-tall +// column = 2 stacked 8x8 tiles (top tile first in every glyph store). +// 0x00 end of string +// 0x0A newline (advance one 16px line) +// 0x20..0x7E ASCII literal -> halfwidth glyph id (byte - 0x20), 1 halfcell +// 0x80|hi, lo fullwidth glyph id ((hi & 0x3F) << 8) | lo, 2 halfcells +// Line wrapping and pagination happen AT COMPILE TIME (per target): the +// runtime only ever sees streams that fit one textbox page. +// --------------------------------------------------------------------------- +export const TOK_END = 0x00; +export const TOK_NEWLINE = 0x0a; +export const TOK_ASCII_MIN = 0x20; +export const TOK_ASCII_MAX = 0x7e; +export const TOK_FULL_FLAG = 0x80; +export const HALF_GLYPH_COUNT = TOK_ASCII_MAX - TOK_ASCII_MIN + 1; // 95 + +// GlyphStore (CHUNK.GLYPHS, id 0; GB/NES: gen_data.c arrays with these counts): +// 0 u16 half_count (always HALF_GLYPH_COUNT for v1) +// 2 u16 full_count (game-specific: unique CJK/fullwidth glyphs used) +// 4 u16 tile_bytes (bytes per 8x8 tile in the target encoding) +// 6 u16 reserved +// 8 ... half glyphs: half_count x 2 tiles (top, bottom) +// ... full glyphs: full_count x 4 tiles (left top, left bottom, +// right top, right bottom) +export const GLYPH_STORE_HEADER_SIZE = 8; // --------------------------------------------------------------------------- // MapChunk (CHUNK.MAP, id = map index). Self-describing; all *_off are @@ -85,7 +206,7 @@ export const GAME_HEADER_SIZE = 44; // 4 u16 num_actors // 6 u16 num_warps // 8 u8 bg_palbank (which 16-color BG palette bank the map tiles use) -// 9 u8 on_load_script (0xFF = none) -- reserved for future +// 9 u8 on_enter_script (script id, 0xFF = none; runs when the map loads) // 10 u16 reserved // 12 u32 tiles_off -> u16[width*height] BG screen-entry tile indices // 16 u32 collision_off -> u8[width*height] (0 = walkable, 1 = solid) @@ -200,6 +321,11 @@ export const OP = { BATTLE: 0x17, // u16 battleId (v1 stub: shows "* battle *" text, push 1=win) WAIT: 0x18, // u16 frames SUSPEND for N frames PLAY_SFX: 0x19, // u16 sfxId (v1 stub, no-op) + LT: 0x1a, // b=pop,a=pop, push (ab) + LE: 0x1c, // push (a<=b) + GE: 0x1d, // push (a>=b) + RND: 0x1e, // u8 n push uniform 0..n-1 (frame-seeded LCG) } as const; export type OpName = keyof typeof OP; @@ -232,17 +358,23 @@ export const OP_OPERAND_BYTES: Record = { [OP.BATTLE]: 2, [OP.WAIT]: 2, [OP.PLAY_SFX]: 2, + [OP.LT]: 0, + [OP.GT]: 0, + [OP.LE]: 0, + [OP.GE]: 0, + [OP.RND]: 1, }; export const VM_MAX_STACK = 16; // --------------------------------------------------------------------------- -// Runtime debug block — written by the runtime into a FIXED EWRAM address so -// the mGBA test harness can read game state without symbols. Layout must match -// runtime/debug.c. Addresses are absolute GBA bus addresses. +// Runtime debug block — written by every runtime into a FIXED RAM address so +// the emulator harnesses (mGBA for GBA/GB, jsnes for NES) can read game state +// without symbols. The LAYOUT is identical on all targets; the base address is +// per-target (TARGETS[t].debugAddr). Layout must match runtime/*/debug.c. // --------------------------------------------------------------------------- export const EWRAM_BASE = 0x02000000; -export const DEBUG_ADDR = EWRAM_BASE; // debug block sits at the top of EWRAM +export const DEBUG_ADDR = EWRAM_BASE; // GBA base (kept for existing GBA paths) // Offsets within the debug block: export const DBG = { MAGIC: 0x00, // u32 'PJDB' @@ -276,18 +408,34 @@ export const flagAddr = (flagId: number): { addr: number; bit: number } => ({ export const BUDGET = { MAX_ACTORS_PER_MAP: 24, MAX_MAPS: 32, - MAX_SPRITES: 16, // one OBJ palette bank per sprite; hardware has 16 banks + MAX_SPRITES: 16, // one OBJ palette bank per sprite; GBA hardware has 16 banks MAX_FLAGS: 128, MAX_VARS: 16, MAX_TEXTS: 512, - MAX_SCRIPTS: 256, + MAX_SCRIPTS: 254, // on-enter script ids must fit a u8 with 0xFF = none // BG char data lives in charblock 0 (512 4bpp tiles); the map screenblock // sits at SBB 8 = the start of charblock 1, so BG tiles must fit in 512. MAX_BG_TILES: 512, MAX_OBJ_TILES: 1024, // OBJ VRAM 0x06010000..0x06018000 = 1024 4bpp tiles MAX_MAP_TILES: 128 * 128, + MAX_FULL_GLYPHS: 512, // unique fullwidth (CJK) glyphs bakeable per game } as const; +// Per-target VRAM budgets for BG tiles (blank + tileset + box + glyph slots). +// GB: signed BG addressing gives 256 tiles; NES: one 256-tile pattern table. +export const BG_TILE_BUDGET: Record = { + gba: 512, + gb: 256, + nes: 256, +}; +// OBJ tile budgets: GB keeps OBJ in 0x8000..0x87FF (128 tiles) so BG can own +// 0x8800..0x97FF; NES pattern table 1 holds 256 8x8 OBJ tiles. +export const OBJ_TILE_BUDGET: Record = { + gba: 1024, + gb: 128, + nes: 256, +}; + // --------------------------------------------------------------------------- // Color: pack an 8-bit RGB triple into GBA BGR555. // --------------------------------------------------------------------------- diff --git a/aot/test/e2e-multi.ts b/aot/test/e2e-multi.ts new file mode 100644 index 0000000..ccbf852 --- /dev/null +++ b/aot/test/e2e-multi.ts @@ -0,0 +1,182 @@ +// aot/test/e2e-multi.ts — the cross-target contract suite: build the SAME +// game (demo/game.tsx) for each requested target and drive the SAME logical +// scenarios through each platform's headless emulator (mGBA for gba/gb, +// jsnes for nes). Identical debug-block layout on every target makes the +// assertions portable; only text pagination differs (computed per target). +// +// bun aot/test/e2e-multi.ts # all built targets +// bun aot/test/e2e-multi.ts gb nes # subset + +import { $ } from "bun"; +import { compile, debugInfo, type CompileOutput } from "../compiler/index.ts"; +import { buildTarget } from "../compiler/targets/index.ts"; +import { wrapPages } from "../compiler/text.ts"; +import { DBG, TARGETS, type TargetName } from "../spec/pjgb.ts"; + +const ROOT = new URL("../..", import.meta.url).pathname; +const RUNNER = ROOT + "aot/test/harness/mgba_runner"; +const NES_RUNNER = ROOT + "aot/test/harness/nes_runner.ts"; +const SHOTS = ROOT + "aot/dist/shots"; +const EXT: Record = { gba: ".gba", gb: ".gb", nes: ".nes" }; + +type Step = + | { op: "advance"; frames: number } + | { op: "press"; buttons: string[]; frames: number; release?: number } + | { op: "read"; name: string; addr: number; size: 1 | 2 | 4 } + | { op: "screenshot"; path: string }; + +let passed = 0; +let failed = 0; +function check(name: string, got: unknown, want: unknown): void { + const ok = got === want; + console.log(` ${ok ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}: got ${got}${ok ? "" : `, want ${want}`}`); + ok ? passed++ : failed++; +} + +interface TargetCtx { + target: TargetName; + rom: string; + built: CompileOutput; + di: { + debugAddr: number; + flags: Record; + texts: string[]; + maps: Record; + }; +} + +async function run(t: TargetCtx, steps: Step[]): Promise> { + const scenario = ROOT + `aot/dist/scenario-${t.target}.json`; + await Bun.write(scenario, JSON.stringify({ steps })); + const out = + t.target === "nes" + ? await $`bun ${NES_RUNNER} ${t.rom} ${scenario}`.text() + : await $`${RUNNER} ${t.rom} ${scenario}`.text(); + const line = out.trim().split("\n").reverse().find((l) => l.trim().startsWith("{")); + if (!line) throw new Error("runner produced no JSON:\n" + out); + const parsed = JSON.parse(line); + if (!parsed.ok) throw new Error("runner error: " + JSON.stringify(parsed)); + return parsed.reads ?? {}; +} + +async function testTarget(target: TargetName): Promise { + console.log(`\n=== ${target.toUpperCase()} ===`); + const built = await compile(ROOT + "aot/demo/game.tsx", target); + const rom = ROOT + `aot/dist/pocket-town${EXT[target]}`; + await buildTarget(built, rom); + const di = debugInfo(built) as TargetCtx["di"]; + const t: TargetCtx = { target, rom, built, di }; + await $`mkdir -p ${SHOTS}`.quiet(); + + const addr = (field: keyof typeof DBG): number => di.debugAddr + DBG[field]; + const R = { + X: addr("PLAYER_X"), + Y: addr("PLAYER_Y"), + DIR: addr("PLAYER_DIR"), + MAP: addr("CUR_MAP"), + TEXT: addr("TEXT_ACTIVE"), + SCRIPT: addr("SCRIPT_ACTIVE"), + CUR: addr("CUR_TEXT"), + BOOT: addr("BOOTED"), + }; + const rd = (name: string, a: number, size: 1 | 2 | 4): Step => ({ op: "read", name, addr: a, size }); + const press = (b: string, frames: number, release = 4): Step => ({ op: "press", buttons: [b], frames, release }); + const shot = (n: string): Step => ({ op: "screenshot", path: `${SHOTS}/${target}_${n}.ppm` }); + + // Per-target pagination of a logical text; returns page text ids. + const pageIds = (s: string): number[] => { + const pages = built.mode === "cjk16" ? wrapPages(s, TARGETS[target]) : [s]; + return pages.map((p) => { + const i = di.texts.indexOf(p); + if (i < 0) throw new Error(`page not found in text bank: ${JSON.stringify(p)}`); + return i; + }); + }; + // A presses that dismiss all pages of a text. + const dismiss = (s: string): Step[] => pageIds(s).map(() => press("A", 1, 10)); + + const HELLO = "You made it! Want to test your first build?"; + const REWARD = "Take this Potion. You will need it."; + const AGAIN = "The road ahead is tougher than it looks."; + const flagBeat = di.flags["beat_rival_1"]; + + console.log("boot & spawn"); + { + const r = await run(t, [ + { op: "advance", frames: 30 }, + rd("boot", R.BOOT, 1), + rd("x", R.X, 2), + rd("y", R.Y, 2), + rd("map", R.MAP, 1), + shot("01_boot"), + ]); + check("booted", r.boot, 1); + check("spawn x", r.x, built.model.start.x); + check("spawn y", r.y, built.model.start.y); + check("start map", r.map, 0); + } + + console.log("rival: dialogue, choice, battle flag, re-talk branch"); + { + const r = await run(t, [ + { op: "advance", frames: 30 }, + press("RIGHT", 8), + press("UP", 20), + press("RIGHT", 4), + rd("faceDir", R.DIR, 1), + rd("faceX", R.X, 2), + press("A", 1, 10), // open dialogue (page 1) + rd("d1_script", R.SCRIPT, 1), + rd("d1_text", R.TEXT, 1), + rd("d1_cur", R.CUR, 2), + shot("02_dialogue"), + ...dismiss(HELLO), // one A per page -> choice menu + rd("choice_script", R.SCRIPT, 1), + rd("choice_text", R.TEXT, 1), + shot("03_choice"), + press("A", 1, 20), // pick "Battle" + rd("won_flag", flagBeat.byteAddr, 1), + rd("won_cur", R.CUR, 2), + shot("04_reward"), + ...dismiss(REWARD), + rd("end_script", R.SCRIPT, 1), + rd("end_text", R.TEXT, 1), + press("A", 1, 10), // talk again -> flag branch + rd("again_cur", R.CUR, 2), + rd("again_text", R.TEXT, 1), + ]); + check("faces right at rival", r.faceDir, 3); + check("blocked by rival (x stays 11)", r.faceX, 11); + check("dialogue: script active", r.d1_script, 1); + check("dialogue: textbox up", r.d1_text, 1); + check("dialogue shows page 1", r.d1_cur, pageIds(HELLO)[0]); + check("choice: script still active", r.choice_script, 1); + check("Battle set beat_rival_1", (r.won_flag >> flagBeat.bit) & 1, 1); + check("reward text shown", r.won_cur, pageIds(REWARD)[0]); + check("script ends after reward", r.end_script, 0); + check("textbox closed at end", r.end_text, 0); + check("re-talk shows flag branch", r.again_cur, pageIds(AGAIN)[0]); + check("re-talk textbox up", r.again_text, 1); + } + + console.log("warp to route101"); + { + const r = await run(t, [ + { op: "advance", frames: 30 }, + press("DOWN", 16, 8), + rd("map", R.MAP, 1), + rd("x", R.X, 2), + shot("05_route"), + ]); + check("warped to route101", r.map, di.maps["route101"]); + check("entrance x", r.x, 9); + } +} + +const args = process.argv.slice(2) as TargetName[]; +const targets: TargetName[] = args.length ? args : (["gba", "gb"] as TargetName[]); +for (const target of targets) { + await testTarget(target); +} +console.log(`\n${failed === 0 ? "\x1b[32m" : "\x1b[31m"}${passed} passed, ${failed} failed\x1b[0m`); +process.exit(failed === 0 ? 0 : 1); diff --git a/aot/test/e2e-shendiao.ts b/aot/test/e2e-shendiao.ts new file mode 100644 index 0000000..b18dd53 --- /dev/null +++ b/aot/test/e2e-shendiao.ts @@ -0,0 +1,253 @@ +// aot/test/e2e-shendiao.ts — full-story E2E for the 神雕旧事 demo: one long +// session per target that plays ALL THREE segments back to back through the +// title menu (segment 1 exploration+item, segment 2 dialogue+choice+jump, +// segment 3 the 7-turn boss battle), then checks the epilogue unlock. +// +// bun aot/test/e2e-shendiao.ts # gba + gb + nes +// bun aot/test/e2e-shendiao.ts gb # subset + +import { $ } from "bun"; +import { compile, debugInfo, type CompileOutput } from "../compiler/index.ts"; +import { buildTarget } from "../compiler/targets/index.ts"; +import { wrapPages } from "../compiler/text.ts"; +import { DBG, TARGETS, type TargetName } from "../spec/pjgb.ts"; + +const ROOT = new URL("../..", import.meta.url).pathname; +const RUNNER = ROOT + "aot/test/harness/mgba_runner"; +const NES_RUNNER = ROOT + "aot/test/harness/nes_runner.ts"; +const SHOTS = ROOT + "aot/dist/shots"; +const EXT: Record = { gba: ".gba", gb: ".gb", nes: ".nes" }; + +type Step = + | { op: "advance"; frames: number } + | { op: "press"; buttons: string[]; frames: number; release?: number } + | { op: "read"; name: string; addr: number; size: 1 | 2 | 4 } + | { op: "screenshot"; path: string }; + +let passed = 0; +let failed = 0; +function check(name: string, got: unknown, want: unknown): void { + const ok = got === want; + console.log(` ${ok ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}: got ${got}${ok ? "" : `, want ${want}`}`); + ok ? passed++ : failed++; +} + +async function testTarget(target: TargetName): Promise { + console.log(`\n=== ${target.toUpperCase()} — 神雕旧事 ===`); + const built: CompileOutput = await compile(ROOT + "aot/demo-shendiao/game.tsx", target); + const rom = ROOT + `aot/dist/shendiao${EXT[target]}`; + await buildTarget(built, rom); + const di = debugInfo(built) as { + debugAddr: number; + flags: Record; + texts: string[]; + maps: Record; + }; + await $`mkdir -p ${SHOTS}`.quiet(); + + const addr = (f: keyof typeof DBG): number => di.debugAddr + DBG[f]; + const R = { + X: addr("PLAYER_X"), + Y: addr("PLAYER_Y"), + MAP: addr("CUR_MAP"), + TEXT: addr("TEXT_ACTIVE"), + SCRIPT: addr("SCRIPT_ACTIVE"), + CUR: addr("CUR_TEXT"), + BOOT: addr("BOOTED"), + }; + const rd = (name: string, a: number, size: 1 | 2 | 4): Step => ({ op: "read", name, addr: a, size }); + const press = (b: string, frames: number, release = 6): Step => ({ op: "press", buttons: [b], frames, release }); + const shot = (n: string): Step => ({ op: "screenshot", path: `${SHOTS}/sd_${target}_${n}.ppm` }); + + const pageIds = (s: string): number[] => + wrapPages(s, TARGETS[target]).map((p) => { + const i = di.texts.indexOf(p); + if (i < 0) throw new Error(`page not in text bank: ${JSON.stringify(p)} (of ${JSON.stringify(s)})`); + return i; + }); + /** One A per page of each text, in order. */ + const dismiss = (...textsToClear: string[]): Step[] => + textsToClear.flatMap((s) => pageIds(s).map(() => press("A", 1, 12))); + /** Move the choice cursor to `idx` and confirm. */ + const pick = (idx: number): Step[] => [ + ...Array.from({ length: idx }, () => press("DOWN", 1, 6)), + press("A", 1, 14), + ]; + const walk = (dir: string, tiles: number): Step => press(dir, tiles * 4, 6); + const face = (dir: string): Step => press(dir, 2, 6); + + const steps: Step[] = []; + const F = di.flags; + + // ---- boot: title menu auto-opens (map onEnter) ---- + steps.push({ op: "advance", frames: 60 }); + steps.push(rd("boot", R.BOOT, 1), rd("m0", R.MAP, 1), rd("t0", R.TEXT, 1), rd("cur0", R.CUR, 2)); + steps.push(shot("01_title")); + + // ===================================================================== + // Segment 1 — 剑冢神雕 (menu option 0) + // ===================================================================== + steps.push(...dismiss("神雕旧事,三段可看。")); + steps.push(...pick(0)); + steps.push(...dismiss("断臂之痛,深谷之中。")); + steps.push(rd("s1_map", R.MAP, 1), rd("s1_x", R.X, 2), rd("s1_y", R.Y, 2)); + + // condor phase 1 (snake gall): spawn (13,12) -> (13,10), face right at (14,10) + steps.push(walk("UP", 2), face("RIGHT"), press("A", 1, 12)); + steps.push(...dismiss("雕:……!", "神雕飞来,放下蛇胆。", "杨过:多谢雕兄!", "雕兄看了看石门。")); + steps.push(rd("gall", F.s1_gall.byteAddr, 1)); + + // to the tomb door: (13,10) UP4 -> (13,6), RIGHT3 -> (16,6), UP5 -> (16,1) + steps.push(walk("UP", 4), walk("RIGHT", 3), walk("UP", 5), face("UP"), press("A", 1, 12)); + steps.push(...dismiss("石门之后,正是剑冢。")); + steps.push(rd("tomb_map", R.MAP, 1)); + steps.push(shot("02_tomb")); + + // heavy sword mound: tomb spawn (9,8) UP5 -> (9,3), RIGHT3 -> (12,3), face mound (12,2) + steps.push(walk("UP", 5), walk("RIGHT", 3), face("UP"), press("A", 1, 12)); + steps.push(...dismiss("黑铁大剑,重不可当。")); + steps.push(...pick(0)); // 拔剑 + steps.push(...dismiss("杨过运力,重剑离石!", "杨过:好剑,好重的剑!")); + steps.push(rd("sword", F.s1_sword.byteAddr, 1)); + steps.push(shot("03_sword")); + + // back out: (12,3) LEFT3, DOWN5, face door (9,9) + steps.push(walk("LEFT", 3), walk("DOWN", 5), face("DOWN"), press("A", 1, 16)); + steps.push(rd("valley_map", R.MAP, 1)); + + // to the condor: door_front (16,1) DOWN5 -> (16,6), LEFT3 -> (13,6), DOWN4 -> (13,10), face right + steps.push(walk("DOWN", 5), walk("LEFT", 3), walk("DOWN", 4), face("RIGHT"), press("A", 1, 12)); + steps.push(...dismiss("雕:……!", "神雕跃入山洪之中。", "杨过:要我在洪水中练剑?")); + // torrent training: 挥剑 x3 + steps.push(...pick(0), ...dismiss("水势如山,剑要脱手。")); + steps.push(...pick(0), ...dismiss("双足生根,剑势渐定。")); + steps.push(...pick(0), ...dismiss("一剑挥出,山洪为之分开!")); + steps.push(...dismiss("杨过:剑重如山,心定如铁。", "从此江湖,有一神雕侠。", "剑冢神雕,到此为止。")); + steps.push({ op: "advance", frames: 30 }); + steps.push(rd("s1_done", F.s1_done.byteAddr, 1), rd("back1_map", R.MAP, 1), rd("menu1", R.TEXT, 1)); + + // ===================================================================== + // Segment 2 — 断肠之约 (menu option 1) + // ===================================================================== + steps.push(...dismiss("神雕旧事,三段可看。")); + steps.push(...pick(1)); + steps.push(...dismiss("十六年后,断肠崖前。", "杨过:龙儿,我来了。")); + steps.push(rd("s2_map", R.MAP, 1)); + + // cliff spawn (8,1) DOWN8 -> (8,9), face the edge (8,10) + steps.push(walk("DOWN", 8), face("DOWN"), press("A", 1, 12)); + steps.push(...dismiss("崖下深谷,深不见底。", "日出日落,无人前来。")); + steps.push(shot("04_cliff")); + steps.push(...pick(2)); // 纵身一跃 + steps.push(...dismiss("杨过:问世间,情是何物!", "龙儿不来,我何必独活!", "纵身一跃,直坠深谷。", "谷底寒潭,白花满谷。", "潭边有人,一身白衣。")); + steps.push(rd("pool_map", R.MAP, 1)); + + // pool spawn (9,2) RIGHT4 -> (13,2), DOWN2 -> (13,4), face 小龙女 (13,5) + steps.push(walk("RIGHT", 4), walk("DOWN", 2), face("DOWN"), press("A", 1, 12)); + steps.push(...dismiss("小龙女:过儿。", "杨过:龙儿!真的是你!", "小龙女:我等了你十六年。", "小龙女:寒潭之下,", "我用古墓功法活了下来。", "杨过:我以为今生,再见不到你。", "小龙女:过儿,你可恨我?")); + steps.push(shot("05_reunion")); + steps.push(...pick(0)); // 不恨 + steps.push(...dismiss("杨过:不恨。你在,就好。", "小龙女:此后生死,再不分离。", "杨过:好。回古墓,回家去。", "断肠之约,到此为止。")); + steps.push({ op: "advance", frames: 30 }); + steps.push(rd("s2_done", F.s2_done.byteAddr, 1), rd("back2_map", R.MAP, 1)); + + // ===================================================================== + // Segment 3 — 襄阳大战 (menu option 2) + // ===================================================================== + steps.push(...dismiss("神雕旧事,三段可看。")); + steps.push(...pick(2)); + steps.push(...dismiss("蒙古大军,围困襄阳。", "高台烈火,郭襄在上。", "杨过:襄儿,我来了!")); + steps.push(rd("s3_map", R.MAP, 1)); + steps.push(shot("06_xiangyang")); + + // spawn (12,3) DOWN4 -> (12,7), RIGHT2 -> (14,7), face 法王 (15,7) + steps.push(walk("DOWN", 4), walk("RIGHT", 2), face("RIGHT"), press("A", 1, 12)); + steps.push(...dismiss("法王:杨过!十六年不见,", "今日再分高下!", "杨过:放了襄儿,再分高下!", "重掌要气,调息回气。")); + + const ZH_HIT = ["黯然销魂,一掌击出!", "法王中掌,连退三步!"]; + const JIAN = ["重剑一挥,势如山洪!"]; + const XI = ["杨过调息,气力渐回。"]; + const E_WHEEL = ["法王:金轮,去!", "金轮飞来,火光四起!"]; + const E_DRAGON = ["法王:龙象神功!", "力大如山,杨过连退三步!"]; + const turn = (idx: number, ...texts: string[]): void => { + steps.push(...pick(idx), ...dismiss(...texts)); + }; + // The deterministic winning line (fw: 60→44→35→26→26→10→1→-8): + turn(0, ...ZH_HIT, ...E_WHEEL); // T1 掌 + turn(1, ...JIAN, ...E_WHEEL); // T2 剑 + turn(1, ...JIAN, ...E_DRAGON); // T3 剑 (enemy 3rd turn) + turn(2, ...XI, ...E_WHEEL); // T4 息 + turn(0, ...ZH_HIT, "法王气息渐乱!", ...E_WHEEL); // T5 掌 (low-HP telegraph) + turn(1, ...JIAN, ...E_DRAGON); // T6 剑 + steps.push(...pick(1)); // T7 剑 — the finishing blow + steps.push(shot("07_battle")); + steps.push( + ...dismiss( + "重剑一挥,势如山洪!", + "黯然销魂掌,天下无双!", + "法王:好掌法……我败了。", + "法王坠地,高台火起!", + "杨过飞身上台,救下郭襄。", + "郭襄:我就知道,大哥哥会来!", + "军前一人,正是大汗蒙哥。", + "杨过飞起一石,正中大汗!", + "大汗坠马,蒙古退兵!", + "郭靖:为国为民,才是真大侠。", + "杨过:有郭伯伯在,襄阳不亡。", + "襄阳大战,到此为止。", + ), + ); + steps.push({ op: "advance", frames: 30 }); + steps.push(rd("s3_done", F.s3_done.byteAddr, 1), rd("back3_map", R.MAP, 1)); + + // ---- epilogue unlock: all three done -> the farewell lines ---- + steps.push(rd("epi_text", R.TEXT, 1), rd("epi_cur", R.CUR, 2)); + steps.push(shot("08_epilogue")); + + // ---- run ---- + const scenario = ROOT + `aot/dist/sd-scenario-${target}.json`; + await Bun.write(scenario, JSON.stringify({ steps })); + const out = + target === "nes" + ? await $`bun ${NES_RUNNER} ${rom} ${scenario}`.text() + : await $`${RUNNER} ${rom} ${scenario}`.quiet().text(); + const line = out.trim().split("\n").reverse().find((l) => l.trim().startsWith("{")); + if (!line) throw new Error("runner produced no JSON:\n" + out.slice(-2000)); + const parsed = JSON.parse(line); + if (!parsed.ok) throw new Error("runner error: " + JSON.stringify(parsed)); + const r = parsed.reads as Record; + + const M = di.maps; + const bit = (v: number, f: { bit: number }): number => (v >> f.bit) & 1; + check("booted", r.boot, 1); + check("boot map = title", r.m0, M.title); + check("title menu auto-opened", r.t0, 1); + check("title greeting shown", r.cur0, pageIds("神雕旧事,三段可看。")[0]); + check("segment 1: warped to valley", r.s1_map, M.valley); + check("segment 1: spawn x", r.s1_x, 13); + check("segment 1: spawn y", r.s1_y, 12); + check("segment 1: got snake gall (s1_gall)", bit(r.gall, F.s1_gall), 1); + check("segment 1: entered the tomb", r.tomb_map, M.tomb); + check("segment 1: pulled the heavy sword (s1_sword)", bit(r.sword, F.s1_sword), 1); + check("segment 1: back to valley", r.valley_map, M.valley); + check("segment 1: completed (s1_done)", bit(r.s1_done, F.s1_done), 1); + check("segment 1: returned to title", r.back1_map, M.title); + check("title menu reopened", r.menu1, 1); + check("segment 2: warped to cliff", r.s2_map, M.cliff); + check("segment 2: the leap reached the pool", r.pool_map, M.pool); + check("segment 2: completed (s2_done)", bit(r.s2_done, F.s2_done), 1); + check("segment 2: returned to title", r.back2_map, M.title); + check("segment 3: warped to xiangyang", r.s3_map, M.xiangyang); + check("segment 3: battle won (s3_done)", bit(r.s3_done, F.s3_done), 1); + check("segment 3: returned to title", r.back3_map, M.title); + check("epilogue textbox up", r.epi_text, 1); + check("epilogue first line", r.epi_cur, pageIds("三段旧事,到此为止。")[0]); +} + +const args = process.argv.slice(2) as TargetName[]; +const targets: TargetName[] = args.length ? args : (["gba", "gb", "nes"] as TargetName[]); +for (const target of targets) { + await testTarget(target); +} +console.log(`\n${failed === 0 ? "\x1b[32m" : "\x1b[31m"}${passed} passed, ${failed} failed\x1b[0m`); +process.exit(failed === 0 ? 0 : 1); diff --git a/aot/test/e2e.ts b/aot/test/e2e.ts index 9ebe59d..523d941 100644 --- a/aot/test/e2e.ts +++ b/aot/test/e2e.ts @@ -6,7 +6,7 @@ import { $ } from "bun"; import { compile, debugInfo } from "../compiler/index.ts"; -import { buildRom } from "../compiler/rom.ts"; +import { buildGba } from "../compiler/targets/gba.ts"; import { DBG, DEBUG_ADDR } from "../spec/pjgb.ts"; const ROOT = new URL("../..", import.meta.url).pathname; @@ -53,9 +53,9 @@ const shot = (n: string): Step => ({ op: "screenshot", path: `${SHOTS}/${n}.ppm` async function main(): Promise { console.log("Building demo ROM..."); - const built = await compile(ROOT + "aot/demo/game.tsx"); + const built = await compile(ROOT + "aot/demo/game.tsx", "gba"); const di = debugInfo(built) as { flags: Record; texts: string[]; maps: Record }; - const rom = await buildRom(built.blob, ROM); + const rom = await buildGba(built, ROM); await $`mkdir -p ${SHOTS}`.quiet(); console.log(`ROM: ${rom.size} bytes\n`); diff --git a/aot/test/handcart.ts b/aot/test/handcart.ts index 35dfa17..708a455 100644 --- a/aot/test/handcart.ts +++ b/aot/test/handcart.ts @@ -207,13 +207,15 @@ code.u8(OP.END); const scriptTable = new ByteWriter(); scriptTable.u32(0); // script 0 at byte 0 -// --- GAME header ------------------------------------------------------------ +// --- GAME header (v2: 48 bytes, ascii8 text mode, no glyph slot region) ------ const game = new ByteWriter(); game .ascii("POCKET TEST", 24) .u8(0).u8(DIR.DOWN).u16(4).u16(4) .u8(1).u8(1).u16(4).u16(texts.length).u16(1) - .u16(FONT_BASE).u16(BOX_TILE).u16(0); + .u16(FONT_BASE).u16(BOX_TILE) + .u8(0).u8(0) // text_mode ascii8, rsv + .u16(0).u16(0); // glyph_slot_base/count // --- assemble --------------------------------------------------------------- function u16buf(a: Uint16Array): Uint8Array { diff --git a/aot/test/harness/mgba_runner.c b/aot/test/harness/mgba_runner.c index 32d1e57..642f853 100644 --- a/aot/test/harness/mgba_runner.c +++ b/aot/test/harness/mgba_runner.c @@ -1,11 +1,13 @@ /* - * mgba_runner.c — headless GBA emulator test runner for PocketJS-AOT. + * mgba_runner.c — headless GBA/GB emulator test runner for PocketJS-AOT. * * Links against libmgba 0.10.5 (Homebrew). Runs a ROM under a scripted * "scenario" of input/advance/read/screenshot steps and prints a JSON - * result to stdout. + * result to stdout. The core is auto-detected from the ROM (mCoreFindVF), + * so the same binary drives .gba and .gb ROMs; the first 8 key bit indices + * are identical on both cores. * - * Usage: mgba_runner + * Usage: mgba_runner * * Scenario shape (see README of the harness): * { "steps": [ @@ -491,10 +493,14 @@ int main(int argc, char** argv) { die("scenario missing \"steps\" array"); } - /* --- Create and initialise the GBA core. --- */ - struct mCore* core = GBACoreCreate(); + /* --- Open the ROM and auto-detect its core (GBA or GB). --- */ + struct VFile* rom = VFileOpen(rom_path, O_RDONLY); + if (!rom) { + die("cannot open ROM: %s", rom_path); + } + struct mCore* core = mCoreFindVF(rom); if (!core) { - die("GBACoreCreate failed"); + die("no emulator core recognizes: %s", rom_path); } if (!core->init(core)) { die("core init failed"); @@ -503,18 +509,13 @@ int main(int argc, char** argv) { /* --- Allocate and register the video buffer. --- */ unsigned width = 0, height = 0; - core->desiredVideoDimensions(core, &width, &height); /* GBA: 240x160 */ + core->desiredVideoDimensions(core, &width, &height); /* GBA 240x160 / GB 160x144 */ color_t* video = malloc((size_t)width * height * BYTES_PER_PIXEL); if (!video) { die("cannot allocate video buffer (%ux%u)", width, height); } core->setVideoBuffer(core, video, width); /* stride == width pixels */ - /* --- Load the ROM (core takes ownership of the VFile). --- */ - struct VFile* rom = VFileOpen(rom_path, O_RDONLY); - if (!rom) { - die("cannot open ROM: %s", rom_path); - } if (!core->loadROM(core, rom)) { die("loadROM failed: %s", rom_path); } diff --git a/aot/test/harness/nes_runner.ts b/aot/test/harness/nes_runner.ts new file mode 100644 index 0000000..5cab185 --- /dev/null +++ b/aot/test/harness/nes_runner.ts @@ -0,0 +1,102 @@ +#!/usr/bin/env bun +// aot/test/harness/nes_runner.ts — headless NES runner (jsnes) speaking the +// same scenario JSON protocol as mgba_runner: +// +// bun nes_runner.ts +// +// Steps: advance / press / read / screenshot. Reads address the CPU bus +// (RAM 0x0000-0x07FF holds the PJ debug block at 0x0700). Output JSON on +// stdout: {"reads": {...}, "ok": true}. + +import { NES, Controller } from "jsnes"; + +const [romPath, scenarioPath] = process.argv.slice(2); +if (!romPath || !scenarioPath) { + console.log(JSON.stringify({ ok: false, error: "usage: nes_runner " })); + process.exit(1); +} + +type Step = + | { op: "advance"; frames: number } + | { op: "press"; buttons: string[]; frames: number; release?: number } + | { op: "read"; name: string; addr: number; size: 1 | 2 | 4 } + | { op: "screenshot"; path: string }; + +const BTN: Record = { + A: Controller.BUTTON_A, + B: Controller.BUTTON_B, + SELECT: Controller.BUTTON_SELECT, + START: Controller.BUTTON_START, + UP: Controller.BUTTON_UP, + DOWN: Controller.BUTTON_DOWN, + LEFT: Controller.BUTTON_LEFT, + RIGHT: Controller.BUTTON_RIGHT, +}; + +let frameBuffer: number[] | null = null; +const nes = new NES({ + onFrame: (fb: number[]) => { + frameBuffer = fb; + }, + onAudioSample: () => {}, +}); + +const rom = await Bun.file(romPath).arrayBuffer(); +const bytes = new Uint8Array(rom); +let romStr = ""; +for (const b of bytes) romStr += String.fromCharCode(b); + +try { + nes.loadROM(romStr); +} catch (e) { + console.log(JSON.stringify({ ok: false, error: `loadROM failed: ${e}` })); + process.exit(1); +} + +const scenario = (await Bun.file(scenarioPath).json()) as { steps: Step[] }; +const reads: Record = {}; + +function busRead(addr: number, size: number): number { + let v = 0; + for (let i = 0; i < size; i++) { + // CPU RAM (with mirroring) lives in nes.cpu.mem. + const a = addr + i; + const byte = a < 0x2000 ? nes.cpu.mem[a & 0x7ff] : nes.cpu.mem[a]; + v |= (byte & 0xff) << (8 * i); + } + return v >>> 0; +} + +function frames(n: number): void { + for (let i = 0; i < n; i++) nes.frame(); +} + +for (const step of scenario.steps) { + if (step.op === "advance") { + frames(step.frames); + } else if (step.op === "press") { + for (const b of step.buttons) nes.buttonDown(1, BTN[b]); + frames(step.frames); + for (const b of step.buttons) nes.buttonUp(1, BTN[b]); + frames(step.release ?? 0); + } else if (step.op === "read") { + reads[step.name] = busRead(step.addr, step.size); + } else if (step.op === "screenshot") { + if (frameBuffer) { + const W = 256; + const H = 240; + const head = `P6\n${W} ${H}\n255\n`; + const out = new Uint8Array(head.length + W * H * 3); + for (let i = 0; i < head.length; i++) out[i] = head.charCodeAt(i); + for (let i = 0; i < W * H; i++) { + const c = frameBuffer[i]; // jsnes: 0xBBGGRR + out[head.length + i * 3] = c & 0xff; + out[head.length + i * 3 + 1] = (c >> 8) & 0xff; + out[head.length + i * 3 + 2] = (c >> 16) & 0xff; + } + await Bun.write(step.path, out); + } + } +} + +console.log(JSON.stringify({ reads, ok: true })); diff --git a/assets/fonts/unifont-16.0.04.hex.gz b/assets/fonts/unifont-16.0.04.hex.gz new file mode 100644 index 0000000..a79ca28 Binary files /dev/null and b/assets/fonts/unifont-16.0.04.hex.gz differ diff --git a/cine/.gitignore b/cine/.gitignore new file mode 100644 index 0000000..60c3973 --- /dev/null +++ b/cine/.gitignore @@ -0,0 +1,4 @@ +dist/ +runtime/gen_data.c +test/art/ +*.__cine.*.mjs diff --git a/cine/README.md b/cine/README.md new file mode 100644 index 0000000..288de54 --- /dev/null +++ b/cine/README.md @@ -0,0 +1,87 @@ +# @pocketjs/cine + +**Author interactive pixel-art life-montage films in TypeScript; ship a real GBA ROM that flexes the 2D hardware.** + +`@pocketjs/cine` is an independent sibling of `@pocketjs/aot`. Where aot compiles tile-RPGs for three consoles, cine is a *cinematic* DSL for one: declarative parallax scenes plus residualized generator timelines, built to push Game Boy Advance Mode-0 as far as it goes — per-scanline gradient skies, alpha and white-out fades, mosaic, affine emblems, WIN0 letterboxing, wave rasters, typewriter CJK captions, PSG micro-sfx, and playable beats (walks, choices, button-mash counters) in every scene. + +The first film is **《渐进人生 · A PROGRESSIVE LIFE》** — an Up-style interactive montage of the life of 尤雨溪 (Evan You), creator of Vue.js and Vite. A fan tribute (同人致敬), unaffiliated; all dialogue is original writing based on public interviews and first-party posts. All art is generated through [PixelLab](https://www.pixellab.ai) with franchise-neutral prompts and committed to `film/art/`. + +## The film + +Boot straight into a chapter menu (真机-friendly), or play the whole montage (~6 minutes): + +1. **486与画笔** — 无锡, 1990s. Walk young him to the family 486; art came before code. +2. **水族馆** — Shanghai high school; a Flash aquarium swims across the projector (wave raster). +3. **一个URL** — New York, 2011. A thick JS book on a 45-minute bus; mash A as a demo climbs Hacker News. +4. **取名那晚** — npm says `seed` is taken; you pick the new name. February 2014: the first stars. +5. **那封信** — New Jersey, Feb 2016. An insurance letter, $4k/month on Patreon, a six-month deal. Choose to jump. *(The scene that earns the montage.)* +6. **星星** — June 16, 2018. The counter rolls past; he walks off the stage and stops watching stars. +7. **OnePiece与闪电** — the two-year Vue 3 rewrite storm, a flag hoisted on 2020-09-18, then a bolt named Vite. +8. **启航** — Singapore, 2024: VoidZero sets sail on Rust keels; 2026: an orange harbor, the flag still reads OPEN & NEUTRAL. +9. **山丘与片尾** — a family on a hill at sunset; credits tick through the anime release code names, A→T… and one unclaimed "U — ???". + +## Authoring model + +Two zones, same discipline as aot: the declaration zone runs at build time; `cue(function* () { ... })` never runs — its AST is lowered to bytecode for a suspendable cue VM. + +```ts +const letter = defineScene({ + id: "letter", + main: image("art/bg_kitchen.png"), + actors: { evan: sprite("art/spr_evan.png", { w: 32, h: 32, at: [66, 104] }) }, + play: cue(function* () { + yield letterbox(14, 1); + yield fadeIn(60); + yield caption("chip", "新泽西 · 2016年2月"); + yield dialog("妻子", "六个月。不行的话,\n我就把你踢回大厂上班。"); + while (yield varEq("jumped", 0)) { + const j = yield choice(["跳", "再想想"]); + if (j === 0) yield setVar("jumped", 1); + } + yield fadeOut(50, "white"); // white-out carries into the next scene + }), +}); +``` + +Fixed layer semantics (Mode 0): BG0 = UI (captions/dialog/choice), BG1 = main stage (up to 512px wide pans), BG2 = far parallax, BG3 = sky — or no sky layer at all when the scene uses a pure raster gradient (the HBlank ISR repaints the backdrop per scanline, so a "15-color" GBA sky gets 160 shades). + +## Build & run + +```bash +# prerequisites: bun, arm-none-eabi-gcc + binutils; mgba for the headless tests +cd cine +bun run build # dist/progressive-life.gba (~235 KB) +bash play.sh # build + open in mGBA.app +bun run test # headless E2E: full playthrough, 27 assertions +bun run smoke # engine smoke film with procedural art (no PixelLab) +bun run art # (re)generate film art via PixelLab — cached, only + # missing files are billed; needs PIXELLAB_API_KEY in ../.env +``` + +Controls: D-pad ←→ for walk beats and menu, A to advance/confirm/mash. In mGBA defaults that's arrows + X. + +The ROM header gets the BIOS logo bitmap + complement checksum (`compiler/rom.ts`), so `dist/progressive-life.gba` is flashcart-ready. + +## Engine layout + +``` +spec/cine.ts the binary contract: cue ops, tween targets, VRAM plan, + debug block (mirrored to runtime/cine_gen.h by spec/gen-c.ts) +dsl/index.ts defineFilm/defineScene/cue + the residual op vocabulary +compiler/ evaluate (Bun temp-module trick) -> residualize (TS AST -> + bytecode) -> assets (median-cut 15-color quantize, H/V-flip + tile dedup, OBJ sheets, gradient tables, Unifont glyph store) + -> emit (gen_data.c) -> rom (link + header pass) +runtime/ fixed C runtime: cue VM, 16-slot tween engine, fx compositor + (BLDCNT state machine, mosaic, WIN0 letterbox, shake, affine), + IWRAM HBlank raster ISR, typewriter caption/dialog/choice UI, + OAM sprites + digit counter HUD, PSG sfx +pixellab/ typed pixflux client + the film's full prompt sheet +test/ e2e.ts (27 asserts), smoke film, ppm->png tooling +``` + +The E2E contract is a fixed debug block at EWRAM `0x02000000` (scene, waiting state, cue ip, camera, vars, sprite 0 position…), driven through the same headless mGBA runner as `aot/` (`aot/test/harness/mgba_runner`). + +## v1 scope + +One BG palette bank per layer (15 colors — PixelLab art quantizes well within it), ≤16 sprites/scene, one affine matrix (one starring emblem at a time), no audio beyond PSG blips, no save. The cue VM has vars/flags/if/while, so menus and branch loops are authored in plain TypeScript control flow. diff --git a/cine/compiler/assets.ts b/cine/compiler/assets.ts new file mode 100644 index 0000000..7ab659b --- /dev/null +++ b/cine/compiler/assets.ts @@ -0,0 +1,357 @@ +// cine/compiler/assets.ts — turn PNGs into GBA-native data: median-cut +// 15-color palettes, 4bpp tiles with H/V-flip dedup, tilemaps, OBJ sheets, +// per-scanline gradient tables, and the built-in UI tiles (box/cursor/digits/ +// A-prompt) rendered from Unifont. + +import { decodePng, type DecodedImage } from "./png.ts"; +import { unifontGlyph, halfcellPixels } from "./cjk.ts"; +import { rgb555, hex555, UI_INK, UI_BOX, UI_ACCENT, UI_SHADOW } from "../spec/cine.ts"; + +export interface Quantized { + /** palette[0] is transparent; entries 1..n are BGR555. */ + pal555: number[]; + indices: Uint8Array; // per pixel palette index (0 = transparent) + w: number; + h: number; +} + +export async function loadPng(path: string): Promise { + const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()); + return decodePng(bytes); +} + +/** Median-cut to <= maxColors opaque colors (+ index 0 transparent). */ +export function quantize(img: DecodedImage, maxColors = 15): Quantized { + const { width: w, height: h, rgba } = img; + const pixels: number[] = []; // packed rgb of opaque pixels + for (let i = 0; i < w * h; i++) { + if (rgba[i * 4 + 3] >= 128) pixels.push((rgba[i * 4] << 16) | (rgba[i * 4 + 1] << 8) | rgba[i * 4 + 2]); + } + // unique colors + const uniq = [...new Set(pixels)]; + let pal: number[]; + if (uniq.length <= maxColors) { + pal = uniq; + } else { + interface Box { colors: number[] } + const boxes: Box[] = [{ colors: uniq }]; + while (boxes.length < maxColors) { + // split the box with the largest channel range + let bi = -1; + let bch = 0; + let brange = -1; + for (let i = 0; i < boxes.length; i++) { + const cs = boxes[i].colors; + if (cs.length < 2) continue; + for (let ch = 0; ch < 3; ch++) { + const sh = 16 - ch * 8; + let lo = 255, hi = 0; + for (const c of cs) { + const v = (c >> sh) & 0xff; + if (v < lo) lo = v; + if (v > hi) hi = v; + } + if (hi - lo > brange) { + brange = hi - lo; + bi = i; + bch = sh; + } + } + } + if (bi < 0) break; + const cs = boxes[bi].colors.sort((a, b) => ((a >> bch) & 0xff) - ((b >> bch) & 0xff)); + const mid = cs.length >> 1; + boxes.splice(bi, 1, { colors: cs.slice(0, mid) }, { colors: cs.slice(mid) }); + } + // box average (weighted by pixel counts) + const counts = new Map(); + for (const p of pixels) counts.set(p, (counts.get(p) ?? 0) + 1); + pal = boxes.map(({ colors }) => { + let r = 0, g = 0, b = 0, n = 0; + for (const c of colors) { + const k = counts.get(c) ?? 1; + r += ((c >> 16) & 0xff) * k; + g += ((c >> 8) & 0xff) * k; + b += (c & 0xff) * k; + n += k; + } + return n ? ((Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n)) : 0; + }); + } + const pal555 = [0, ...pal.map((c) => rgb555((c >> 16) & 0xff, (c >> 8) & 0xff, c & 0xff))]; + // nearest-color mapping + const cache = new Map(); + const indices = new Uint8Array(w * h); + for (let i = 0; i < w * h; i++) { + if (rgba[i * 4 + 3] < 128) { + indices[i] = 0; + continue; + } + const c = (rgba[i * 4] << 16) | (rgba[i * 4 + 1] << 8) | rgba[i * 4 + 2]; + let best = cache.get(c); + if (best === undefined) { + let bd = Infinity; + best = 1; + for (let p = 0; p < pal.length; p++) { + const dr = ((c >> 16) & 0xff) - ((pal[p] >> 16) & 0xff); + const dg = ((c >> 8) & 0xff) - ((pal[p] >> 8) & 0xff); + const db = (c & 0xff) - (pal[p] & 0xff); + const d = dr * dr * 3 + dg * dg * 6 + db * db; + if (d < bd) { + bd = d; + best = p + 1; + } + } + cache.set(c, best); + } + indices[i] = best; + } + return { pal555, indices, w, h }; +} + +/** 4bpp tile from a 64-entry palette-index array. */ +export function tile4(px: ArrayLike): Uint8Array { + const out = new Uint8Array(32); + for (let row = 0; row < 8; row++) + for (let c = 0; c < 4; c++) { + out[row * 4 + c] = (px[row * 8 + c * 2] & 0xf) | ((px[row * 8 + c * 2 + 1] & 0xf) << 4); + } + return out; +} + +function tileKey(t: Uint8Array): string { + return Buffer.from(t).toString("base64"); +} +function flipH(px: number[]): number[] { + const o = new Array(64); + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) o[y * 8 + x] = px[y * 8 + (7 - x)]; + return o; +} +function flipV(px: number[]): number[] { + const o = new Array(64); + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) o[y * 8 + x] = px[(7 - y) * 8 + x]; + return o; +} + +export interface TiledLayer { + tiles: Uint8Array[]; // deduped, NOT including the shared blank tile 0 + /** map entries: tileIndex (1-based within this layer) | flip bits; 0 = blank */ + cells: { tile: number; hflip: boolean; vflip: boolean }[]; + cols: number; + rows: number; +} + +/** Cut an indexed image into deduped 8x8 tiles (H/V flip aware). */ +export function tileLayer(q: Quantized): TiledLayer { + const cols = Math.ceil(q.w / 8); + const rows = Math.ceil(q.h / 8); + const tiles: Uint8Array[] = []; + const seen = new Map(); + const cells: TiledLayer["cells"] = []; + for (let ty = 0; ty < rows; ty++) { + for (let tx = 0; tx < cols; tx++) { + const px = new Array(64).fill(0); + let allZero = true; + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + const sx = tx * 8 + x; + const sy = ty * 8 + y; + const v = sx < q.w && sy < q.h ? q.indices[sy * q.w + sx] : 0; + px[y * 8 + x] = v; + if (v) allZero = false; + } + if (allZero) { + cells.push({ tile: 0, hflip: false, vflip: false }); + continue; + } + const key = tileKey(tile4(px)); + const hit = seen.get(key); + if (hit) { + cells.push({ tile: hit.idx, hflip: hit.h, vflip: hit.v }); + continue; + } + const idx = tiles.length + 1; + tiles.push(tile4(px)); + seen.set(key, { idx, h: false, v: false }); + const fh = flipH(px); + const fv = flipV(px); + const fhv = flipV(fh); + for (const [p, hh, vv] of [ + [fh, true, false], + [fv, false, true], + [fhv, true, true], + ] as const) { + const k = tileKey(tile4(p)); + if (!seen.has(k)) seen.set(k, { idx, h: hh, v: vv }); + } + cells.push({ tile: idx, hflip: false, vflip: false }); + } + } + return { tiles, cells, cols, rows }; +} + +/** Build a screenblock map (32x32 or 64x32) from a tiled layer. */ +export function buildMap( + layer: TiledLayer, + wide: boolean, + tileBase: number, + palbank: number, + rowOff = 0, +): Uint16Array { + const mapCols = wide ? 64 : 32; + const out = new Uint16Array(mapCols * 32); + for (let r0 = 0; r0 < layer.rows && r0 + rowOff < 32; r0++) { + const r = r0 + rowOff; + for (let c = 0; c < layer.cols && c < mapCols; c++) { + const cell = layer.cells[r0 * layer.cols + c]; + if (cell.tile === 0) continue; + let se = ((tileBase + cell.tile - 1) & 0x3ff) | ((palbank & 0xf) << 12); + if (cell.hflip) se |= 0x400; + if (cell.vflip) se |= 0x800; + // 64-wide maps are stored as two consecutive 32x32 screenblocks + if (wide) { + const sb = c >> 5; + out[sb * 1024 + r * 32 + (c & 31)] = se; + } else { + out[r * 32 + c] = se; + } + } + } + return out; +} + +/** OBJ sheet: horizontal frame strip -> 4bpp tiles in 1D frame-major order. */ +export function tileObjSheet(q: Quantized, fw: number, fh: number, frames: number): Uint8Array { + const tw = fw / 8; + const th = fh / 8; + const out = new Uint8Array(frames * tw * th * 32); + let o = 0; + for (let f = 0; f < frames; f++) { + for (let ty = 0; ty < th; ty++) { + for (let tx = 0; tx < tw; tx++) { + const px = new Array(64).fill(0); + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + const sx = f * fw + tx * 8 + x; + const sy = ty * 8 + y; + if (sx < q.w && sy < q.h) px[y * 8 + x] = q.indices[sy * q.w + sx]; + } + out.set(tile4(px), o); + o += 32; + } + } + } + return out; +} + +/** 160-entry per-scanline gradient (multi-stop, lerp in RGB). */ +export function gradientTable(stops: string[]): Uint16Array { + const rgb = stops.map((s) => { + const h = s.replace("#", ""); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; + }); + const out = new Uint16Array(160); + const segs = rgb.length - 1; + for (let y = 0; y < 160; y++) { + const t = (y / 159) * segs; + const si = Math.min(segs - 1, Math.floor(t)); + const f = t - si; + const a = rgb[si]; + const b = rgb[si + 1]; + out[y] = rgb555( + Math.round(a[0] + (b[0] - a[0]) * f), + Math.round(a[1] + (b[1] - a[1]) * f), + Math.round(a[2] + (b[2] - a[2]) * f), + ); + } + return out; +} + +// --- built-in UI assets ----------------------------------------------------------- + +/** UI BG palette bank 15 colors (index -> BGR555). */ +export function uiPalette(): number[] { + const bank = new Array(16).fill(0); + bank[UI_INK] = hex555("#f2f5f7"); + bank[UI_BOX] = hex555("#141c2a"); + bank[UI_ACCENT] = hex555("#42b883"); + bank[UI_SHADOW] = hex555("#5a6478"); + return bank; +} + +/** 4 fixed BG tiles: blank, box fill, accent underline, choice cursor. */ +export function uiBgTiles(): Uint8Array { + const out = new Uint8Array(4 * 32); + // 0: blank (all transparent) + // 1: box fill + out.set(tile4(new Array(64).fill(UI_BOX)), 32); + // 2: accent underline: box with a 2px green line at the bottom + { + const px = new Array(64).fill(UI_BOX); + for (let x = 0; x < 8; x++) { + px[5 * 8 + x] = UI_ACCENT; + px[6 * 8 + x] = UI_ACCENT; + } + out.set(tile4(px), 64); + } + // 3: cursor arrow (ink on box) + { + const px = new Array(64).fill(UI_BOX); + for (let y = 1; y < 7; y++) { + const span = y < 4 ? y : 7 - y; + for (let x = 1; x <= 1 + span; x++) px[y * 8 + x] = UI_ACCENT; + } + out.set(tile4(px), 96); + } + return out; +} + +/** OBJ UI sheet: A-prompt 16x16 (4 tiles) + digits 0-9 as 8x16 (2 tiles each). */ +export function uiObjTiles(): Uint8Array { + const tiles: Uint8Array[] = []; + // A-prompt: dark disc, green rim, white 'A' + { + const grid = new Array(256).fill(0); + const cx = 7.5, cy = 7.5; + for (let y = 0; y < 16; y++) + for (let x = 0; x < 16; x++) { + const d = Math.hypot(x - cx, y - cy); + if (d <= 6.2) grid[y * 16 + x] = UI_BOX; + else if (d <= 7.6) grid[y * 16 + x] = UI_ACCENT; + } + const glyph = unifontGlyph(0x41); // 'A' + const [top, bottom] = halfcellPixels(glyph, 0, UI_INK, 99); + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + if (top[y * 8 + x] === UI_INK && y >= 2) grid[(y + 1) * 16 + (x + 4)] = UI_INK; + if (bottom[y * 8 + x] === UI_INK && y <= 5) grid[(y + 9) * 16 + (x + 4)] = UI_INK; + } + for (const [ox, oy] of [ + [0, 0], + [8, 0], + [0, 8], + [8, 8], + ]) { + const px = new Array(64); + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) px[y * 8 + x] = grid[(oy + y) * 16 + (ox + x)]; + tiles.push(tile4(px)); + } + } + // digits 0-9: unifont halfwidth, ink with shadow, transparent bg, 8x16 (2 tiles) + for (let d = 0; d <= 9; d++) { + const glyph = unifontGlyph(0x30 + d); + const [top, bottom] = halfcellPixels(glyph, 0, UI_INK, 0); + for (const half of [top, bottom]) { + // drop shadow: shift down-right in UI_SHADOW + const px = new Array(64).fill(0); + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + if (half[y * 8 + x] === UI_INK) px[y * 8 + x] = UI_INK; + } + tiles.push(tile4(px)); + } + } + const out = new Uint8Array(tiles.length * 32); + tiles.forEach((t, i) => out.set(t, i * 32)); + return out; +} diff --git a/cine/compiler/cjk.ts b/cine/compiler/cjk.ts new file mode 100644 index 0000000..f6cccd8 --- /dev/null +++ b/cine/compiler/cjk.ts @@ -0,0 +1,89 @@ +// cine/compiler/cjk.ts — GNU Unifont loader (copied from aot; same font asset). +// +// Unifont ships every BMP glyph as a 16px-tall 1-bit bitmap in .hex format: +// one line per codepoint, "XXXX:", where halfwidth glyphs are 8px wide +// (32 hex digits) and fullwidth glyphs 16px wide (64 hex digits). That makes +// it the single cross-target glyph source: the compiler bakes only the glyphs +// a game actually uses, in each target's native tile encoding. +// +// Unifont is dual-licensed (GNU GPLv2+ with the font embedding exception / +// SIL OFL 1.1); embedding rendered glyphs in a ROM is explicitly permitted. + +import { gunzipSync } from "bun"; +import { readFileSync } from "node:fs"; + +const HEX_PATH = new URL("../../assets/fonts/unifont-16.0.04.hex.gz", import.meta.url).pathname; + +export interface UnifontGlyph { + /** 8 or 16 pixels wide; always 16 tall. */ + width: 8 | 16; + /** 16 rows; each row is a bitmask, MSB = leftmost pixel. */ + rows: Uint16Array; // length 16 +} + +let table: Map | null = null; + +function load(): Map { + if (table) return table; + const text = new TextDecoder().decode(gunzipSync(readFileSync(HEX_PATH))); + table = new Map(); + for (const line of text.split("\n")) { + const colon = line.indexOf(":"); + if (colon <= 0) continue; + const cp = parseInt(line.slice(0, colon), 16); + const hex = line.slice(colon + 1).trim(); + if (hex.length !== 32 && hex.length !== 64) continue; + const width = hex.length === 32 ? 8 : 16; + const rows = new Uint16Array(16); + const digitsPerRow = width / 4; + for (let r = 0; r < 16; r++) { + rows[r] = parseInt(hex.slice(r * digitsPerRow, (r + 1) * digitsPerRow), 16); + } + table.set(cp, { width: width as 8 | 16, rows }); + } + return table; +} + +/** Glyph for a codepoint, or null if Unifont has none. */ +export function unifontGlyph(cp: number): UnifontGlyph | null { + return load().get(cp) ?? null; +} + +/** True if the char renders fullwidth (2 halfcells). ASCII is halfwidth. */ +export function isFullwidth(ch: string): boolean { + const cp = ch.codePointAt(0)!; + if (cp <= 0x7e) return false; + const g = unifontGlyph(cp); + if (!g) return true; // unknown chars reserve a full cell (rendered blank) + return g.width === 16; +} + +/** + * Rasterize one halfcell (8x16 column) of a glyph into two stacked 8x8 + * palette-index tiles: [top 64 px, bottom 64 px], row-major. + * + * `half` selects the left (0) or right (1) 8px column of a 16px glyph. + * `ink`/`bg` are the palette indices to write. + */ +export function halfcellPixels( + glyph: UnifontGlyph | null, + half: 0 | 1, + ink: number, + bg: number, +): [number[], number[]] { + const top = new Array(64).fill(bg); + const bottom = new Array(64).fill(bg); + if (glyph) { + const shiftBase = glyph.width - 8 * (half + 1); // bits below the column + for (let y = 0; y < 16; y++) { + const row = glyph.rows[y]; + const dst = y < 8 ? top : bottom; + const dy = y & 7; + for (let x = 0; x < 8; x++) { + const bit = (row >> (shiftBase + 7 - x)) & 1; + if (bit) dst[dy * 8 + x] = ink; + } + } + } + return [top, bottom]; +} diff --git a/cine/compiler/cli.ts b/cine/compiler/cli.ts new file mode 100644 index 0000000..d74c786 --- /dev/null +++ b/cine/compiler/cli.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env bun +// cine/compiler/cli.ts — `bun cine/compiler/cli.ts build film.tsx --out out.gba` + +import { compileFilm } from "./index.ts"; +import { emitGenData } from "./emit.ts"; +import { buildRom } from "./rom.ts"; + +const args = process.argv.slice(2); +if (args[0] !== "build" || !args[1]) { + console.error("usage: cine build [--out dist/film.gba] [--title TITLE]"); + process.exit(1); +} +const entry = args[1]; +const out = args.includes("--out") ? args[args.indexOf("--out") + 1] : new URL("../dist/film.gba", import.meta.url).pathname; +const title = args.includes("--title") ? args[args.indexOf("--title") + 1] : "CINE"; + +const film = await compileFilm(entry); +const rom = await buildRom(emitGenData(film), out, title); +await Bun.write(out + ".debug.json", JSON.stringify(film.debug, null, 2)); +console.log( + `cine: ${rom.gba} (${rom.size} bytes), ${film.scenes.length} scenes, ` + + `${film.debug.texts.length} texts, ${film.nHalfcells} glyph halfcells`, +); +for (const s of film.scenes) { + console.log( + ` scene ${s.id}: main ${s.nMain}t${s.wide ? " wide" : ""}, shared ${s.nShared}t, ` + + `obj ${s.objTiles.length / 32}t, cue ${s.cue.length}B`, + ); +} diff --git a/cine/compiler/emit.ts b/cine/compiler/emit.ts new file mode 100644 index 0000000..34a2231 --- /dev/null +++ b/cine/compiler/emit.ts @@ -0,0 +1,96 @@ +// cine/compiler/emit.ts — residualize the CompiledFilm into runtime/gen_data.c. +// Everything is plain C arrays in ROM; no container parsing at runtime. + +import type { CompiledFilm, CompiledScene } from "./index.ts"; + +function u8arr(name: string, data: Uint8Array): string { + const lines: string[] = [`static const u8 ${name}[] = {`]; + for (let i = 0; i < data.length; i += 24) { + lines.push(" " + Array.from(data.subarray(i, i + 24)).join(",") + ","); + } + lines.push("};"); + return lines.join("\n"); +} + +function u16arr(name: string, data: Uint16Array | number[]): string { + const arr = Array.from(data); + const lines: string[] = [`static const u16 ${name}[] = {`]; + for (let i = 0; i < arr.length; i += 16) { + lines.push(" " + arr.slice(i, i + 16).map((v) => "0x" + v.toString(16)).join(",") + ","); + } + lines.push("};"); + return lines.join("\n"); +} + +function u32arr(name: string, data: number[]): string { + const lines: string[] = [`static const u32 ${name}[] = {`]; + for (let i = 0; i < data.length; i += 12) { + lines.push(" " + data.slice(i, i + 12).map((v) => String(v >>> 0)).join(",") + ","); + } + lines.push("};"); + return lines.join("\n"); +} + +export function emitGenData(film: CompiledFilm): string { + const parts: string[] = [ + "/* gen_data.c — GENERATED by cine/compiler. Do not edit. */", + '#include "cine.h"', + "", + ]; + + film.scenes.forEach((s: CompiledScene, i: number) => { + parts.push(u16arr(`s${i}_pal_bg`, s.palBg)); + parts.push(u16arr(`s${i}_pal_obj`, s.palObj)); + parts.push(u8arr(`s${i}_tiles_main`, s.tilesMain)); + if (s.nShared) parts.push(u8arr(`s${i}_tiles_shared`, s.tilesShared)); + parts.push(u16arr(`s${i}_map_main`, s.mapMain)); + if (s.mapFar) parts.push(u16arr(`s${i}_map_far`, s.mapFar)); + if (s.mapSky) parts.push(u16arr(`s${i}_map_sky`, s.mapSky)); + if (s.gradient) parts.push(u16arr(`s${i}_grad`, s.gradient)); + if (s.objTiles.length) parts.push(u8arr(`s${i}_obj`, s.objTiles)); + if (s.protos.length) { + const rows = s.protos + .map((p) => ` {${p.tileBase},${p.w},${p.h},${p.frames},${p.palbank},${p.fps}},`) + .join("\n"); + parts.push(`static const CineProto s${i}_protos[] = {\n${rows}\n};`); + } + parts.push(u8arr(`s${i}_cue`, s.cue)); + parts.push(""); + }); + + parts.push(u32arr("film_text_offs", film.textOffs)); + parts.push(u8arr("film_text_blob", film.textBlob)); + parts.push(u8arr("film_glyphs", film.glyphs)); + parts.push(u8arr("film_ui_bg", film.uiBg)); + parts.push(u8arr("film_ui_obj", film.uiObj)); + parts.push(""); + + const rows = film.scenes.map((s, i) => { + const f = (b: boolean, t: string) => (b ? t : "0"); + return [ + " {", + `s${i}_pal_bg, s${i}_pal_obj,`, + `s${i}_tiles_main, ${s.nMain},`, + `${s.nShared ? `s${i}_tiles_shared` : "0"}, ${s.nShared},`, + `s${i}_map_main,`, + `${f(!!s.mapFar, `s${i}_map_far`)}, ${f(!!s.mapSky, `s${i}_map_sky`)},`, + `${s.wide ? 1 : 0},`, + `${s.farFacQ8}, ${s.skyFacQ8}, ${s.farVxQ8}, ${s.skyVxQ8},`, + `${f(!!s.gradient, `s${i}_grad`)},`, + `${s.objTiles.length ? `s${i}_obj` : "0"}, ${s.objTiles.length},`, + `${s.protos.length ? `s${i}_protos` : "0"}, ${s.protos.length},`, + `s${i}_cue, ${s.cue.length},`, + `${s.cam0}, ${s.camMin}, ${s.camMax},`, + `${s.rasterMode}, ${s.rasterAmp}, ${s.letterbox0}, 0x${s.backdrop.toString(16)}`, + "},", + ].join(" "); + }); + parts.push(`static const CineScene film_scenes[] = {\n${rows.join("\n")}\n};`); + parts.push(""); + parts.push( + `const CineFilm film = { film_scenes, ${film.scenes.length}, film_text_offs, film_text_blob, ` + + `${film.textOffs.length}, film_glyphs, ${film.nHalfcells}, film_ui_bg, film_ui_obj, ${film.uiObj.length} };`, + ); + parts.push(""); + return parts.join("\n"); +} diff --git a/cine/compiler/evaluate.ts b/cine/compiler/evaluate.ts new file mode 100644 index 0000000..649c82a --- /dev/null +++ b/cine/compiler/evaluate.ts @@ -0,0 +1,90 @@ +// cine/compiler/evaluate.ts — execute the declaration zone, capture cue ASTs. +// Same bridge as aot: every `cue(function*(){...})` argument is recorded and +// replaced by its id, then the rewritten module is transpiled and imported so +// the DSL builders fill the shared registry. Temp module names carry a +// per-process counter because Bun caches modules by path. + +import ts from "typescript"; +import { pathToFileURL } from "node:url"; + +const DSL_INDEX = new URL("../dsl/index.ts", import.meta.url).pathname; + +let evalCounter = 0; + +export interface CueSite { + id: number; + body: ts.FunctionExpression | ts.ArrowFunction; + file: ts.SourceFile; +} + +export interface EvalResult { + registry: import("../dsl/index.ts").Registry; + cues: CueSite[]; +} + +function findCueCalls(sf: ts.SourceFile): ts.CallExpression[] { + const out: ts.CallExpression[] = []; + const visit = (n: ts.Node): void => { + if ( + ts.isCallExpression(n) && + ts.isIdentifier(n.expression) && + n.expression.text === "cue" && + n.arguments.length === 1 + ) { + out.push(n); + return; + } + ts.forEachChild(n, visit); + }; + visit(sf); + out.sort((a, b) => a.getStart() - b.getStart()); + return out; +} + +export async function evaluateFilm(entryPath: string): Promise { + const source = await Bun.file(entryPath).text(); + const sf = ts.createSourceFile(entryPath, source, ts.ScriptTarget.ES2020, true, ts.ScriptKind.TS); + + const calls = findCueCalls(sf); + const cues: CueSite[] = []; + const edits: { start: number; end: number; text: string }[] = []; + calls.forEach((call, id) => { + const arg = call.arguments[0]; + if (!ts.isFunctionExpression(arg) && !ts.isArrowFunction(arg)) { + throw new Error(`cue() argument must be a function expression (cue #${id})`); + } + cues.push({ id, body: arg, file: sf }); + edits.push({ start: arg.getStart(sf), end: arg.getEnd(), text: String(id) }); + }); + edits.sort((a, b) => b.start - a.start); + let rewritten = source; + for (const e of edits) rewritten = rewritten.slice(0, e.start) + e.text + rewritten.slice(e.end); + rewritten = rewritten.replace(/(["'])@pocketjs\/cine\1/g, JSON.stringify(DSL_INDEX)); + + const js = ts.transpileModule(rewritten, { + fileName: entryPath, + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + + const dsl = (await import(DSL_INDEX)) as typeof import("../dsl/index.ts"); + dsl.__resetRegistry(); + + const tmp = entryPath + `.__cine.${process.pid}.${evalCounter++}.mjs`; + await Bun.write(tmp, js); + try { + await import(pathToFileURL(tmp).href); + } finally { + await Bun.file(tmp) + .exists() + .then((e) => (e ? Bun.$`rm -f ${tmp}`.quiet() : null)) + .catch(() => {}); + } + + const registry = dsl.__getRegistry(); + if (!registry.film) throw new Error("no defineFilm() found in " + entryPath); + return { registry, cues }; +} diff --git a/cine/compiler/index.ts b/cine/compiler/index.ts new file mode 100644 index 0000000..16adca5 --- /dev/null +++ b/cine/compiler/index.ts @@ -0,0 +1,316 @@ +// cine/compiler/index.ts — compileFilm(entry): evaluate the declaration zone, +// bake assets, residualize cues, and assemble the CompiledFilm model that +// emit.ts turns into gen_data.c. + +import { dirname, resolve } from "node:path"; +import { evaluateFilm } from "./evaluate.ts"; +import { residualizeCue, type CueCtx } from "./residualize.ts"; +import { TextBank } from "./text.ts"; +import { + loadPng, quantize, tileLayer, buildMap, tileObjSheet, gradientTable, + uiPalette, uiBgTiles, uiObjTiles, type Quantized, +} from "./assets.ts"; +import { + FARSKY_BASE, FARSKY_MAX, MAIN_TILE_MAX, GLYPH_SLOTS, + PALBANK_SKY, PALBANK_FAR, PALBANK_MAIN, PALBANK_UI, PALBANK_OBJ_UI, + MAX_SPRITES, MAX_SCENES, hex555, UI_INK, UI_BOX, +} from "../spec/cine.ts"; +import type { ActorDecl, LayerDecl, SceneDecl } from "../dsl/index.ts"; + +export interface CompiledProto { + tileBase: number; + w: number; + h: number; + frames: number; + palbank: number; + fps: number; +} + +export interface CompiledScene { + id: string; + palBg: Uint16Array; // 256 + palObj: Uint16Array; // 256 + tilesMain: Uint8Array; + nMain: number; + tilesShared: Uint8Array; + nShared: number; + mapMain: Uint16Array; + mapFar: Uint16Array | null; + mapSky: Uint16Array | null; + wide: boolean; + farFacQ8: number; + skyFacQ8: number; + farVxQ8: number; + skyVxQ8: number; + gradient: Uint16Array | null; + objTiles: Uint8Array; + protos: CompiledProto[]; + cue: Uint8Array; + cam0: number; + camMin: number; + camMax: number; + rasterMode: number; + rasterAmp: number; + letterbox0: number; + backdrop: number; +} + +export interface CompiledFilm { + title: string; + scenes: CompiledScene[]; + textOffs: number[]; + textBlob: Uint8Array; + glyphs: Uint8Array; + nHalfcells: number; + uiBg: Uint8Array; + uiObj: Uint8Array; + debug: { + sceneIds: Record; + texts: string[]; + vars: Record; + flags: Record; + }; +} + +const RASTER_OFF = 0, RASTER_GRADIENT = 1, RASTER_WAVE_MAIN = 2, RASTER_WAVE_FAR = 3; + +export async function compileFilm(entryPath: string): Promise { + const entry = resolve(entryPath); + const base = dirname(entry); + const { registry, cues } = await evaluateFilm(entry); + const film = registry.film!; + if (film.scenes.length === 0) throw new Error("film has no scenes"); + if (film.scenes.length > MAX_SCENES) throw new Error(`too many scenes (max ${MAX_SCENES})`); + + const sceneIndex = new Map(); + film.scenes.forEach((s, i) => { + if (sceneIndex.has(s.id)) throw new Error(`duplicate scene id ${s.id}`); + sceneIndex.set(s.id, i); + }); + + const texts = new TextBank(); + const vars = new Map(); + const flags = new Map(); + + const scenes: CompiledScene[] = []; + for (const decl of film.scenes) { + scenes.push(await compileScene(decl, base, { texts, vars, flags, sceneIndex, cues })); + } + + const { offs, blob } = texts.buildBlob(); + const glyphs = texts.bakeGlyphStore(UI_INK, UI_BOX); + + return { + title: film.title, + scenes, + textOffs: offs, + textBlob: blob, + glyphs, + nHalfcells: glyphs.length / 64, + uiBg: uiBgTiles(), + uiObj: uiObjTiles(), + debug: { + sceneIds: Object.fromEntries(sceneIndex), + texts: texts.entries.map((e) => e.raw), + vars: Object.fromEntries(vars), + flags: Object.fromEntries(flags), + }, + }; +} + +interface SceneEnv { + texts: TextBank; + vars: Map; + flags: Map; + sceneIndex: Map; + cues: import("./evaluate.ts").CueSite[]; +} + +async function loadLayer(base: string, layer: LayerDecl): Promise { + const img = await loadPng(resolve(base, layer.png)); + return quantize(img, 15); +} + +async function compileScene(decl: SceneDecl, base: string, env: SceneEnv): Promise { + const palBg = new Uint16Array(256); + const palObj = new Uint16Array(256); + + // UI palettes (BG bank 15 + OBJ bank 15) + uiPalette().forEach((c, i) => { + palBg[PALBANK_UI * 16 + i] = c; + palObj[PALBANK_OBJ_UI * 16 + i] = c; + }); + + const backdrop = hex555(decl.backdrop ?? "#000000"); + palBg[0] = backdrop; + + // --- main layer -> charblock 0 ------------------------------------------------ + let tilesMain = new Uint8Array(32); // tile 0 blank + let nMain = 1; + let mapMain = new Uint16Array(1024); + let wide = false; + let imgW = 240; + if (decl.main) { + const q = await loadLayer(base, decl.main); + imgW = q.w; + wide = q.w > 240; + if (wide && q.w > 512) throw new Error(`[${decl.id}] main image too wide (max 512): ${q.w}`); + const tl = tileLayer(q); + if (1 + tl.tiles.length > MAIN_TILE_MAX) throw new Error(`[${decl.id}] main tiles ${tl.tiles.length} > budget`); + tilesMain = new Uint8Array((1 + tl.tiles.length) * 32); + tl.tiles.forEach((t, i) => tilesMain.set(t, (1 + i) * 32)); + nMain = 1 + tl.tiles.length; + mapMain = buildMap(tl, wide, 1, PALBANK_MAIN); + q.pal555.forEach((c, i) => { + if (i > 0) palBg[PALBANK_MAIN * 16 + i] = c; + }); + } + + // --- far + sky -> shared charblock ----------------------------------------------- + const sharedTiles: Uint8Array[] = []; + let mapFar: Uint16Array | null = null; + let mapSky: Uint16Array | null = null; + let gradient: Uint16Array | null = null; + let farFacQ8 = Math.round((decl.far?.scroll ?? 0.4) * 256); + let skyFacQ8 = 0; + const farVxQ8 = Math.round((decl.far?.vx ?? 0) * 256); + let skyVxQ8 = 0; + + if (decl.far) { + const q = await loadLayer(base, decl.far); + const tl = tileLayer(q); + const tileBase = FARSKY_BASE + sharedTiles.length; + sharedTiles.push(...tl.tiles); + mapFar = buildMap(tl, false, tileBase, PALBANK_FAR, (decl.far.y ?? 0) >> 3); + q.pal555.forEach((c, i) => { + if (i > 0) palBg[PALBANK_FAR * 16 + i] = c; + }); + } + if (decl.sky) { + if (decl.sky.kind === "gradient") { + gradient = gradientTable(decl.sky.stops); + } else { + const q = await loadPng(resolve(base, decl.sky.png)).then((img) => quantize(img, 15)); + const tl = tileLayer(q); + const tileBase = FARSKY_BASE + sharedTiles.length; + sharedTiles.push(...tl.tiles); + mapSky = buildMap(tl, false, tileBase, PALBANK_SKY, (decl.sky.y ?? 0) >> 3); + q.pal555.forEach((c, i) => { + if (i > 0) palBg[PALBANK_SKY * 16 + i] = c; + }); + skyFacQ8 = Math.round((decl.sky.scroll ?? 0.15) * 256); + skyVxQ8 = Math.round((decl.sky.vx ?? 0) * 256); + } + } + if (sharedTiles.length > FARSKY_MAX) throw new Error(`[${decl.id}] far+sky tiles ${sharedTiles.length} > ${FARSKY_MAX}`); + const tilesShared = new Uint8Array(sharedTiles.length * 32); + sharedTiles.forEach((t, i) => tilesShared.set(t, i * 32)); + + // --- actors -> protos + OBJ sheet ---------------------------------------------------- + const actorEntries = Object.entries(decl.actors ?? {}); + if (actorEntries.length > MAX_SPRITES) throw new Error(`[${decl.id}] too many actors (max ${MAX_SPRITES})`); + const protos: CompiledProto[] = []; + const protoByPng = new Map(); + const objParts: Uint8Array[] = []; + let objTileCursor = 0; + const actors: CueCtx["actors"] = new Map(); + let nextObjBank = 0; + + for (const [name, a] of actorEntries) { + const key = `${a.png}|${a.w}x${a.h}x${a.frames ?? 1}`; + let protoIdx = protoByPng.get(key); + if (protoIdx === undefined) { + const img = await loadPng(resolve(base, a.png)); + const frames = a.frames ?? 1; + if (img.width < a.w * frames || img.height < a.h) { + throw new Error(`[${decl.id}] sprite ${a.png}: ${img.width}x${img.height} < ${a.w * frames}x${a.h}`); + } + const q = quantize(img, 15); + if (nextObjBank >= PALBANK_OBJ_UI) throw new Error(`[${decl.id}] too many OBJ palettes`); + const bank = nextObjBank++; + q.pal555.forEach((c, i) => { + if (i > 0) palObj[bank * 16 + i] = c; + }); + const sheet = tileObjSheet(q, a.w, a.h, frames); + protoIdx = protos.length; + protos.push({ + tileBase: objTileCursor, + w: a.w, + h: a.h, + frames, + palbank: bank, + fps: a.fps ?? 10, + }); + objTileCursor += sheet.length / 32; + objParts.push(sheet); + protoByPng.set(key, protoIdx); + } + actors.set(name, { slot: actors.size, proto: protoIdx, decl: a }); + } + if (objTileCursor > 1000) throw new Error(`[${decl.id}] OBJ tiles ${objTileCursor} > 1000 budget`); + const objTiles = new Uint8Array(objParts.reduce((n, p) => n + p.length, 0)); + { + let o = 0; + for (const p of objParts) { + objTiles.set(p, o); + o += p.length; + } + } + + // --- cue ------------------------------------------------------------------------------ + const cueRef = decl.play; + if (!cueRef || typeof (cueRef as { __cue?: number }).__cue !== "number") { + throw new Error(`[${decl.id}] scene has no play: cue(...)`); + } + const site = env.cues.find((c) => c.id === (cueRef as { __cue: number }).__cue); + if (!site) throw new Error(`[${decl.id}] cue site not found`); + const cue = residualizeCue(site.body, { + texts: env.texts, + vars: env.vars, + flags: env.flags, + sceneIndex: env.sceneIndex, + actors, + cueName: decl.id, + }); + + // --- camera + raster defaults ---------------------------------------------------------- + const camMin = decl.camera?.min ?? 0; + const camMax = decl.camera?.max ?? Math.max(0, imgW - 240); + const cam0 = decl.camera?.start ?? camMin; + let rasterMode = RASTER_OFF; + let rasterAmp = 0; + if (gradient) rasterMode = RASTER_GRADIENT; + if (decl.wave) { + rasterMode = decl.wave.layer === "far" ? RASTER_WAVE_FAR : RASTER_WAVE_MAIN; + rasterAmp = decl.wave.amp; + } + + return { + id: decl.id, + palBg, + palObj, + tilesMain, + nMain, + tilesShared, + nShared: sharedTiles.length, + mapMain, + mapFar, + mapSky, + wide, + farFacQ8, + skyFacQ8, + farVxQ8, + skyVxQ8, + gradient, + objTiles, + protos, + cue, + cam0, + camMin, + camMax, + rasterMode, + rasterAmp, + letterbox0: decl.letterbox ?? 0, + backdrop, + }; +} diff --git a/cine/compiler/png.ts b/cine/compiler/png.ts new file mode 100644 index 0000000..8b1bb19 --- /dev/null +++ b/cine/compiler/png.ts @@ -0,0 +1,155 @@ +// cine/compiler/png.ts — minimal PNG codec (build-time only), self-contained so +// @pocketjs/cine stays independent. Decoder adapted from compiler/pak.ts, +// encoder from scripts/psp-all.ts. + +import { inflateSync, deflateSync } from "node:zlib"; + +export interface DecodedImage { + width: number; + height: number; + /** RGBA, 4 bytes/px, row-major. */ + rgba: Uint8Array; +} + +/** Decode an 8-bit RGB/RGBA/grayscale non-interlaced PNG to RGBA. */ +export function decodePng(bytes: Uint8Array): DecodedImage { + const SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + for (let i = 0; i < 8; i++) { + if (bytes[i] !== SIG[i]) throw new Error("png: bad signature"); + } + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let o = 8; + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = 0; + const idat: Uint8Array[] = []; + while (o + 8 <= bytes.length) { + const len = dv.getUint32(o, false); + const type = String.fromCharCode(bytes[o + 4], bytes[o + 5], bytes[o + 6], bytes[o + 7]); + const body = bytes.subarray(o + 8, o + 8 + len); + if (type === "IHDR") { + width = dv.getUint32(o + 8, false); + height = dv.getUint32(o + 12, false); + bitDepth = bytes[o + 16]; + colorType = bytes[o + 17]; + if (bytes[o + 20] !== 0) throw new Error("png: interlaced PNGs unsupported"); + } else if (type === "IDAT") { + idat.push(body); + } else if (type === "IEND") { + break; + } + o += 12 + len; + } + if (bitDepth !== 8) throw new Error(`png: only 8-bit supported (got ${bitDepth})`); + const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colorType]; + if (!channels) throw new Error(`png: unsupported color type ${colorType} (palette PNGs: re-export as RGBA)`); + + const zdata = new Uint8Array(idat.reduce((n, c) => n + c.length, 0)); + let zo = 0; + for (const c of idat) { + zdata.set(c, zo); + zo += c.length; + } + const raw = new Uint8Array(inflateSync(zdata)); + + const stride = width * channels; + const rgba = new Uint8Array(width * height * 4); + const prev = new Uint8Array(stride); + const line = new Uint8Array(stride); + let ro = 0; + for (let y = 0; y < height; y++) { + const filter = raw[ro++]; + for (let x = 0; x < stride; x++) { + const cur = raw[ro + x]; + const a = x >= channels ? line[x - channels] : 0; + const b = prev[x]; + const c = x >= channels ? prev[x - channels] : 0; + let v: number; + switch (filter) { + case 0: v = cur; break; + case 1: v = cur + a; break; + case 2: v = cur + b; break; + case 3: v = cur + ((a + b) >> 1); break; + case 4: { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + v = cur + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c); + break; + } + default: throw new Error(`png: bad filter ${filter}`); + } + line[x] = v & 0xff; + } + ro += stride; + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + const s = x * channels; + switch (colorType) { + case 0: rgba[i] = rgba[i + 1] = rgba[i + 2] = line[s]; rgba[i + 3] = 255; break; + case 2: rgba[i] = line[s]; rgba[i + 1] = line[s + 1]; rgba[i + 2] = line[s + 2]; rgba[i + 3] = 255; break; + case 4: rgba[i] = rgba[i + 1] = rgba[i + 2] = line[s]; rgba[i + 3] = line[s + 1]; break; + case 6: rgba[i] = line[s]; rgba[i + 1] = line[s + 1]; rgba[i + 2] = line[s + 2]; rgba[i + 3] = line[s + 3]; break; + } + } + prev.set(line); + } + return { width, height, rgba }; +} + +// --- encoder ------------------------------------------------------------------- + +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +function crc32(buf: Uint8Array): number { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type: string, data: Uint8Array): Uint8Array { + const out = new Uint8Array(12 + data.length); + const dv = new DataView(out.buffer); + dv.setUint32(0, data.length, false); + for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i); + out.set(data, 8); + const body = out.subarray(4, 8 + data.length); + dv.setUint32(8 + data.length, crc32(body), false); + return out; +} + +export function encodePng(rgba: Uint8Array, w: number, h: number): Uint8Array { + const stride = w * 4; + const raw = new Uint8Array((stride + 1) * h); + for (let y = 0; y < h; y++) { + raw[y * (stride + 1)] = 0; + raw.set(rgba.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1); + } + const ihdr = new Uint8Array(13); + const dv = new DataView(ihdr.buffer); + dv.setUint32(0, w, false); + dv.setUint32(4, h, false); + ihdr[8] = 8; + ihdr[9] = 6; + const sig = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + const idat = new Uint8Array(deflateSync(raw)); + const parts = [sig, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0))]; + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.length; + } + return out; +} diff --git a/cine/compiler/residualize.ts b/cine/compiler/residualize.ts new file mode 100644 index 0000000..6f40cee --- /dev/null +++ b/cine/compiler/residualize.ts @@ -0,0 +1,442 @@ +// cine/compiler/residualize.ts — lower cue generator ASTs to cue-VM bytecode. +// +// Recognized statement forms (anything else is a compile error, on purpose — +// residual code is a DSL, not JavaScript): +// yield op(...); op from the vocabulary table below +// const x = yield choice([...]); result var (also hasFlag/rnd/var*) +// if (cond) {...} else {...} cond: x === n | x < n | yield hasFlag() +// | yield varXx() | !cond | (cond) +// while (cond) {...} break; return; +// setVar/addVar/setFlag/clrFlag via yield like any op. + +import ts from "typescript"; +import { + OP, TW, ByteWriter, CMP_EQ, CMP_NE, CMP_LT, CMP_GT, CMP_LE, CMP_GE, + FADE_IN_BLACK, FADE_OUT_BLACK, FADE_IN_WHITE, FADE_OUT_WHITE, + RASTER_OFF, RASTER_GRADIENT, RASTER_WAVE_MAIN, RASTER_WAVE_FAR, + CAP_CHIP, CAP_SUB, CAP_CARD, + EASE_LINEAR, EASE_IN, EASE_OUT, EASE_INOUT, + SFX_BLIP, SFX_CONFIRM, SFX_WHOOSH, SFX_STAR, + SPR_HFLIP, SPR_SCREEN, SPR_BEHIND, SPR_GHOST, + TW_SPRITE_BASE, N_VARS, N_FLAGS, MAX_CHOICES, +} from "../spec/cine.ts"; +import type { TextBank } from "./text.ts"; +import type { ActorDecl } from "../dsl/index.ts"; + +export interface CueCtx { + texts: TextBank; + vars: Map; + flags: Map; + sceneIndex: Map; + /** current scene's actors: name -> { slot, proto, decl } */ + actors: Map; + cueName: string; +} + +const EASES: Record = { linear: EASE_LINEAR, in: EASE_IN, out: EASE_OUT, inout: EASE_INOUT }; +const SFXS: Record = { blip: SFX_BLIP, confirm: SFX_CONFIRM, whoosh: SFX_WHOOSH, star: SFX_STAR }; +const CAPS: Record = { chip: CAP_CHIP, sub: CAP_SUB, card: CAP_CARD }; + +export function residualizeCue(body: ts.FunctionExpression | ts.ArrowFunction, ctx: CueCtx): Uint8Array { + const w = new ByteWriter(); + const endJumps: number[] = []; + const breakStack: number[][] = []; + + const err = (n: ts.Node, msg: string): never => { + const sf = n.getSourceFile(); + const { line } = sf.getLineAndCharacterOfPosition(n.getStart()); + throw new Error(`[${ctx.cueName}] ${msg} (line ${line + 1}): ${n.getText().slice(0, 80)}`); + }; + + const internVar = (name: string): number => { + let id = ctx.vars.get(name); + if (id === undefined) { + id = ctx.vars.size; + if (id >= N_VARS) throw new Error(`too many vars (max ${N_VARS}): ${name}`); + ctx.vars.set(name, id); + } + return id; + }; + const internFlag = (name: string): number => { + let id = ctx.flags.get(name); + if (id === undefined) { + id = ctx.flags.size; + if (id >= N_FLAGS) throw new Error(`too many flags (max ${N_FLAGS}): ${name}`); + ctx.flags.set(name, id); + } + return id; + }; + + const num = (n: ts.Expression): number => { + if (ts.isNumericLiteral(n)) return Number(n.text); + if (ts.isPrefixUnaryExpression(n) && n.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(n.operand)) + return -Number(n.operand.text); + return err(n, "expected a numeric literal"); + }; + const str = (n: ts.Expression): string => { + if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) return n.text; + return err(n, "expected a string literal"); + }; + const actor = (n: ts.Expression): { slot: number; proto: number; decl: ActorDecl } => { + const name = str(n); + const a = ctx.actors.get(name); + if (!a) return err(n, `unknown actor "${name}" in this scene`); + return a; + }; + + const asCall = (e: ts.Expression): { name: string; args: readonly ts.Expression[] } | null => { + if (ts.isCallExpression(e) && ts.isIdentifier(e.expression)) return { name: e.expression.text, args: e.arguments }; + return null; + }; + + /** value-producing yields usable in conditions / assignments (push result) */ + const emitValueYield = (call: { name: string; args: readonly ts.Expression[] }, n: ts.Node): void => { + switch (call.name) { + case "choice": { + const arr = call.args[0]; + if (!arr || !ts.isArrayLiteralExpression(arr)) return err(n, "choice() takes an array literal"); + const ids = arr.elements.map((e) => ctx.texts.intern(str(e as ts.Expression), { cols: 22, maxLines: 1 })); + if (ids.length < 2 || ids.length > MAX_CHOICES) return err(n, `choice() needs 2..${MAX_CHOICES} options`); + w.u8(OP.CHOICE).u8(ids.length); + for (const id of ids) w.u16(id); + return; + } + case "hasFlag": + w.u8(OP.GET_FLAG).u8(internFlag(str(call.args[0]))); + return; + case "rnd": + w.u8(OP.RND).u8(num(call.args[0])); + return; + case "varEq": + case "varNe": + case "varLt": + case "varGt": + case "varLe": + case "varGe": { + const kind = { varEq: CMP_EQ, varNe: CMP_NE, varLt: CMP_LT, varGt: CMP_GT, varLe: CMP_LE, varGe: CMP_GE }[ + call.name + ]!; + w.u8(OP.GET_VAR).u8(internVar(str(call.args[0]))); + w.u8(OP.PUSH).i16(num(call.args[1])); + w.u8(OP.CMP).u8(kind); + return; + } + default: + return err(n, `${call.name}() does not produce a value here`); + } + }; + + /** condition -> leaves 0/1-ish on stack */ + const emitCond = (e: ts.Expression): void => { + if (ts.isParenthesizedExpression(e)) return emitCond(e.expression); + if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.ExclamationToken) { + emitCond(e.operand); + w.u8(OP.PUSH).i16(0); + w.u8(OP.CMP).u8(CMP_EQ); + return; + } + if (ts.isYieldExpression(e) && e.expression) { + const call = asCall(e.expression); + if (!call) return err(e, "yield in condition must be a call"); + return emitValueYield(call, e); + } + if (ts.isBinaryExpression(e)) { + const KINDS: Partial> = { + [ts.SyntaxKind.EqualsEqualsEqualsToken]: CMP_EQ, + [ts.SyntaxKind.EqualsEqualsToken]: CMP_EQ, + [ts.SyntaxKind.ExclamationEqualsEqualsToken]: CMP_NE, + [ts.SyntaxKind.ExclamationEqualsToken]: CMP_NE, + [ts.SyntaxKind.LessThanToken]: CMP_LT, + [ts.SyntaxKind.GreaterThanToken]: CMP_GT, + [ts.SyntaxKind.LessThanEqualsToken]: CMP_LE, + [ts.SyntaxKind.GreaterThanEqualsToken]: CMP_GE, + }; + const kind = KINDS[e.operatorToken.kind]; + if (kind === undefined) return err(e, "unsupported comparison"); + if (!ts.isIdentifier(e.left)) return err(e, "left side must be a result variable"); + w.u8(OP.GET_VAR).u8(internVar(localName(e.left.text))); + w.u8(OP.PUSH).i16(num(e.right)); + w.u8(OP.CMP).u8(kind); + return; + } + if (ts.isIdentifier(e)) { + w.u8(OP.GET_VAR).u8(internVar(localName(e.text))); + return; + } + return err(e, "unsupported condition"); + }; + + const localName = (name: string): string => `${ctx.cueName}:${name}`; + + /** statement-position yield op table */ + const emitOp = (call: { name: string; args: readonly ts.Expression[] }, n: ts.Node): void => { + const a = call.args; + const easeArg = (i: number, dflt: number): number => { + if (!a[i]) return dflt; + const e = EASES[str(a[i])]; + if (e === undefined) return err(n, `unknown ease ${a[i].getText()}`); + return e; + }; + switch (call.name) { + case "fadeIn": + case "fadeOut": { + const frames = a[0] ? num(a[0]) : 30; + const white = a[1] ? str(a[1]) === "white" : false; + const mode = call.name === "fadeIn" ? (white ? FADE_IN_WHITE : FADE_IN_BLACK) : white ? FADE_OUT_WHITE : FADE_OUT_BLACK; + w.u8(OP.FADE).u8(mode).u16(frames); + return; + } + case "wait": + w.u8(OP.WAIT).u16(num(a[0])); + return; + case "waitA": + w.u8(OP.WAITA); + return; + case "waitTweens": + w.u8(OP.WAIT_TWEENS); + return; + case "caption": { + const style = CAPS[str(a[0])]; + if (style === undefined) return err(n, "caption style must be chip|sub|card"); + const opts = style === CAP_CHIP ? { cols: 24, maxLines: 1 } : {}; + w.u8(OP.CAPTION).u8(style).u16(ctx.texts.intern(str(a[1]), opts)); + return; + } + case "captionClear": { + const s = a[0] ? str(a[0]) : "all"; + w.u8(OP.CAPTION_CLR).u8(s === "all" ? 0xff : (CAPS[s] ?? 0xff)); + return; + } + case "dialog": + w.u8(OP.DIALOG) + .u16(ctx.texts.intern(str(a[0]), { cols: 12, maxLines: 1 })) + .u16(ctx.texts.intern(str(a[1]))); + return; + case "pan": + w.u8(OP.TWEEN).u8(TW.CAM_X).u8(easeArg(2, EASE_INOUT)).i16(num(a[0])).u16(num(a[1])); + return; + case "panY": + w.u8(OP.TWEEN).u8(TW.CAM_Y).u8(easeArg(2, EASE_INOUT)).i16(num(a[0])).u16(num(a[1])); + return; + case "alpha": + w.u8(OP.TWEEN).u8(TW.EVA).u8(EASE_LINEAR).i16(num(a[0])).u16(num(a[2])); + w.u8(OP.TWEEN).u8(TW.EVB).u8(EASE_LINEAR).i16(num(a[1])).u16(num(a[2])); + return; + case "mosaicTo": + w.u8(OP.TWEEN).u8(TW.MOSAIC).u8(EASE_LINEAR).i16(num(a[0])).u16(num(a[1])); + return; + case "shake": + w.u8(OP.TWEEN).u8(TW.SHAKE).u8(EASE_LINEAR).i16(num(a[0])).u16(0); + w.u8(OP.TWEEN).u8(TW.SHAKE).u8(EASE_OUT).i16(0).u16(num(a[1])); + return; + case "autoScroll": { + const t = str(a[0]) === "far" ? TW.FAR_VX : TW.SKY_VX; + w.u8(OP.TWEEN).u8(t).u8(EASE_LINEAR).i16(Math.round(numF(a[1]) * 256)).u16(a[2] ? num(a[2]) : 0); + return; + } + case "zoom": + w.u8(OP.TWEEN).u8(TW.OBJ_SCALE).u8(easeArg(2, EASE_INOUT)).i16(Math.round(numF(a[0]) * 256)).u16(num(a[1])); + return; + case "spinTo": + w.u8(OP.TWEEN).u8(TW.OBJ_ANGLE).u8(easeArg(2, EASE_LINEAR)).i16(Math.round((num(a[0]) / 360) * 256)).u16(num(a[1])); + return; + case "letterbox": + w.u8(OP.LETTERBOX).u8(num(a[0])).u16(a[1] ? num(a[1]) : 20); + return; + case "rasterWave": + w.u8(OP.RASTER).u8(str(a[0]) === "far" ? RASTER_WAVE_FAR : RASTER_WAVE_MAIN).u8(num(a[1])); + return; + case "rasterGradient": + w.u8(OP.RASTER).u8(RASTER_GRADIENT).u8(0); + return; + case "rasterOff": + w.u8(OP.RASTER).u8(RASTER_OFF).u8(0); + return; + case "show": { + const ac = actor(a[0]); + const x = a[1] ? num(a[1]) : (ac.decl.at?.[0] ?? 0); + const y = a[2] ? num(a[2]) : (ac.decl.at?.[1] ?? 0); + let flags = 0; + if (ac.decl.flip) flags |= SPR_HFLIP; + if (ac.decl.screen) flags |= SPR_SCREEN; + if (ac.decl.behind) flags |= SPR_BEHIND; + if (ac.decl.ghost) flags |= SPR_GHOST; + if (a[3] && ts.isObjectLiteralExpression(a[3])) { + for (const p of a[3].properties) { + if (ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "flip") { + if (p.initializer.kind === ts.SyntaxKind.TrueKeyword) flags |= SPR_HFLIP; + else flags &= ~SPR_HFLIP; + } + } + } + w.u8(OP.SPRITE_SHOW).u8(ac.slot).u8(ac.proto).i16(x).i16(y).u8(flags); + return; + } + case "hide": + w.u8(OP.SPRITE_HIDE).u8(actor(a[0]).slot); + return; + case "animate": { + const ac = actor(a[0]); + const mode = ts.isStringLiteral(a[1]) ? 1 : 0; + const arg = mode === 1 ? (a[2] ? num(a[2]) : 0) : num(a[1]); + w.u8(OP.SPRITE_ANIM).u8(ac.slot).u8(mode).u8(arg); + return; + } + case "moveTo": { + const ac = actor(a[0]); + w.u8(OP.SPRITE_MOVE).u8(ac.slot).u8(easeArg(3 + 1, EASE_INOUT)).i16(num(a[1])).i16(num(a[2])).u16(num(a[3])); + return; + } + case "walkTo": { + const ac = actor(a[0]); + w.u8(OP.SPRITE_ANIM).u8(ac.slot).u8(1).u8(0); + w.u8(OP.TWEEN).u8(TW_SPRITE_BASE + ac.slot * 2).u8(EASE_LINEAR).i16(num(a[1])).u16(num(a[2])); + w.u8(OP.WAIT_TWEENS); + w.u8(OP.SPRITE_ANIM).u8(ac.slot).u8(0).u8(0); + return; + } + case "control": { + const ac = actor(a[0]); + const speed = a[2] ? numF(a[2]) : 1.5; + w.u8(OP.CONTROL).u8(ac.slot).i16(num(a[1])).u8(Math.max(1, Math.round(speed * 16))); + return; + } + case "mash": + w.u8(OP.MASH).u8(internVar(str(a[0]))).u16(num(a[1])); + return; + case "counter": + w.u8(OP.COUNTER).u8(internVar(str(a[0]))).u8(1).i16(num(a[1])).i16(num(a[2])); + return; + case "counterHide": + w.u8(OP.COUNTER).u8(0).u8(0).i16(0).i16(0); + return; + case "affineOn": + w.u8(OP.AFFINE).u8(actor(a[0]).slot).u8(1); + return; + case "affineOff": + w.u8(OP.AFFINE).u8(actor(a[0]).slot).u8(0); + return; + case "sfx": { + const id = SFXS[str(a[0])]; + if (id === undefined) return err(n, "unknown sfx"); + w.u8(OP.SFX).u8(id); + return; + } + case "gotoScene": { + const idx = ctx.sceneIndex.get(str(a[0])); + if (idx === undefined) return err(n, `unknown scene "${str(a[0])}"`); + w.u8(OP.GOTO_SCENE).u8(idx); + return; + } + case "setFlag": + w.u8(OP.SET_FLAG).u8(internFlag(str(a[0]))); + return; + case "clrFlag": + w.u8(OP.CLR_FLAG).u8(internFlag(str(a[0]))); + return; + case "setVar": + w.u8(OP.PUSH).i16(num(a[1])); + w.u8(OP.SET_VAR).u8(internVar(str(a[0]))); + return; + case "addVar": + w.u8(OP.ADD_VAR).u8(internVar(str(a[0]))).i16(num(a[1])); + return; + default: + return err(n, `unknown cue op ${call.name}()`); + } + }; + + const numF = (e: ts.Expression): number => { + if (ts.isNumericLiteral(e)) return Number(e.text); + if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(e.operand)) + return -Number(e.operand.text); + return err(e, "expected a number"); + }; + + const emitStmt = (s: ts.Statement): void => { + if (ts.isExpressionStatement(s)) { + const e = s.expression; + if (ts.isYieldExpression(e) && e.expression) { + const call = asCall(e.expression); + if (!call) return void err(s, "yield must wrap a cue op call"); + emitOp(call, s); + return; + } + return void err(s, "only `yield op(...)` expression statements are allowed"); + } + if (ts.isVariableStatement(s)) { + for (const d of s.declarationList.declarations) { + if (!d.initializer || !ts.isYieldExpression(d.initializer) || !d.initializer.expression) + return void err(s, "const x = yield (...) is the only declaration form"); + const call = asCall(d.initializer.expression); + if (!call) return void err(s, "expected a value-producing cue op"); + emitValueYield(call, s); + if (!ts.isIdentifier(d.name)) return void err(s, "destructuring not supported"); + w.u8(OP.SET_VAR).u8(internVar(localName(d.name.text))); + } + return; + } + if (ts.isIfStatement(s)) { + emitCond(s.expression); + w.u8(OP.JZ); + const jzAt = w.length; + w.u16(0); + emitBlock(s.thenStatement); + if (s.elseStatement) { + w.u8(OP.JMP); + const jmpAt = w.length; + w.u16(0); + w.patchU16(jzAt, w.length); + emitBlock(s.elseStatement); + w.patchU16(jmpAt, w.length); + } else { + w.patchU16(jzAt, w.length); + } + return; + } + if (ts.isWhileStatement(s)) { + const loopStart = w.length; + emitCond(s.expression); + w.u8(OP.JZ); + const jzAt = w.length; + w.u16(0); + breakStack.push([]); + emitBlock(s.statement); + w.u8(OP.JMP).u16(loopStart); + w.patchU16(jzAt, w.length); + for (const b of breakStack.pop()!) w.patchU16(b, w.length); + return; + } + if (ts.isBreakStatement(s)) { + if (!breakStack.length) return void err(s, "break outside while"); + w.u8(OP.JMP); + breakStack[breakStack.length - 1].push(w.length); + w.u16(0); + return; + } + if (ts.isReturnStatement(s)) { + w.u8(OP.JMP); + endJumps.push(w.length); + w.u16(0); + return; + } + if (ts.isBlock(s)) { + for (const st of s.statements) emitStmt(st); + return; + } + if (ts.isEmptyStatement(s)) return; + return void err(s, `unsupported statement kind ${ts.SyntaxKind[s.kind]}`); + }; + + const emitBlock = (s: ts.Statement): void => { + if (ts.isBlock(s)) for (const st of s.statements) emitStmt(st); + else emitStmt(s); + }; + + if (!body.body || !ts.isBlock(body.body)) throw new Error(`[${ctx.cueName}] cue body must be a block`); + for (const st of body.body.statements) emitStmt(st); + + for (const j of endJumps) w.patchU16(j, w.length); + w.u8(OP.END); + return w.toUint8Array(); +} diff --git a/cine/compiler/rom.ts b/cine/compiler/rom.ts new file mode 100644 index 0000000..022bf40 --- /dev/null +++ b/cine/compiler/rom.ts @@ -0,0 +1,69 @@ +// cine/compiler/rom.ts — link the runtime + gen_data.c into a real .gba ROM +// and patch the header (BIOS logo bitmap + title + complement checksum) so it +// boots on hardware, not just emulators. + +import { $ } from "bun"; + +const RT = new URL("../runtime", import.meta.url).pathname; +const DIST = new URL("../dist", import.meta.url).pathname; + +// prettier-ignore +const GBA_LOGO = [ + 0x24, 0xff, 0xae, 0x51, 0x69, 0x9a, 0xa2, 0x21, 0x3d, 0x84, 0x82, 0x0a, 0x84, 0xe4, 0x09, 0xad, + 0x11, 0x24, 0x8b, 0x98, 0xc0, 0x81, 0x7f, 0x21, 0xa3, 0x52, 0xbe, 0x19, 0x93, 0x09, 0xce, 0x20, + 0x10, 0x46, 0x4a, 0x4a, 0xf8, 0x27, 0x31, 0xec, 0x58, 0xc7, 0xe8, 0x33, 0x82, 0xe3, 0xce, 0xbf, + 0x85, 0xf4, 0xdf, 0x94, 0xce, 0x4b, 0x09, 0xc1, 0x94, 0x56, 0x8a, 0xc0, 0x13, 0x72, 0xa7, 0xfc, + 0x9f, 0x84, 0x4d, 0x73, 0xa3, 0xca, 0x9a, 0x61, 0x58, 0x97, 0xa3, 0x27, 0xfc, 0x03, 0x98, 0x76, + 0x23, 0x1d, 0xc7, 0x61, 0x03, 0x04, 0xae, 0x56, 0xbf, 0x38, 0x84, 0x00, 0x40, 0xa7, 0x0e, 0xfd, + 0xff, 0x52, 0xfe, 0x03, 0x6f, 0x95, 0x30, 0xf1, 0x97, 0xfb, 0xc0, 0x85, 0x60, 0xd6, 0x80, 0x25, + 0xa9, 0x63, 0xbe, 0x03, 0x01, 0x4e, 0x38, 0xe2, 0xf9, 0xa2, 0x34, 0xff, 0xbb, 0x3e, 0x03, 0x44, + 0x78, 0x00, 0x90, 0xcb, 0x88, 0x11, 0x3a, 0x94, 0x65, 0xc0, 0x7c, 0x63, 0x87, 0xf0, 0x3c, 0xaf, + 0xd6, 0x25, 0xe4, 0x8b, 0x38, 0x0a, 0xac, 0x72, 0x21, 0xd4, 0xf8, 0x07, +]; + +function patchHeader(rom: Uint8Array, title: string): void { + if (rom.length < 0xc0) throw new Error("ROM too small for a GBA header"); + rom.set(GBA_LOGO, 0x04); + const t = title.replace(/[^\x20-\x7e]/g, "").toUpperCase().slice(0, 12).padEnd(12, "\0"); + for (let i = 0; i < 12; i++) rom[0xa0 + i] = t.charCodeAt(i); + rom[0xb2] = 0x96; + let sum = 0; + for (let a = 0xa0; a <= 0xbc; a++) sum += rom[a]; + rom[0xbd] = (-(0x19 + sum)) & 0xff; +} + +export interface BuildRomResult { + gba: string; + elf: string; + size: number; +} + +export async function buildRom(genDataC: string, outPath: string, title = "CINE"): Promise { + await Bun.write(RT + "/gen_data.c", genDataC); + await $`mkdir -p ${DIST}`.quiet(); + + const elf = DIST + "/film.elf"; + const CFLAGS = [ + "-mcpu=arm7tdmi", + "-marm", + "-ffreestanding", + "-nostdlib", + "-O2", + "-fno-strict-aliasing", + "-Wall", + ]; + const sources = [ + `${RT}/crt0.s`, + ...["main", "video", "irq", "input", "tween", "fx", "obj", "caption", "cue_vm", "sfx", "debug", "gen_data"].map( + (m) => `${RT}/${m}.c`, + ), + ]; + + await $`arm-none-eabi-gcc ${CFLAGS} -I${RT} -T${RT}/gba.ld ${sources} -lgcc -o ${elf}`.quiet(); + await $`arm-none-eabi-objcopy -O binary ${elf} ${outPath}`.quiet(); + + const rom = new Uint8Array(await Bun.file(outPath).arrayBuffer()); + patchHeader(rom, title); + await Bun.write(outPath, rom); + return { gba: outPath, elf, size: rom.length }; +} diff --git a/cine/compiler/text.ts b/cine/compiler/text.ts new file mode 100644 index 0000000..76051a3 --- /dev/null +++ b/cine/compiler/text.ts @@ -0,0 +1,146 @@ +// cine/compiler/text.ts — caption text bank: wrap, tokenize, glyph interning. +// Captions are at most C_CAP_LINES x CAP_COLS cells; the compiler wraps and +// validates at build time so the runtime never measures anything. + +import { CAP_COLS, CAP_LINES, TOK_END, TOK_NL, ByteWriter } from "../spec/cine.ts"; +import { unifontGlyph, halfcellPixels } from "./cjk.ts"; + +// Anything outside printable ASCII goes through the fullwidth-glyph path and +// occupies 2 cells (halfwidth Unifont glyphs get a blank right half). +const isAscii = (ch: string): boolean => { + const cp = ch.codePointAt(0)!; + return cp >= 0x20 && cp <= 0x7e; +}; +const cellW = (ch: string): number => (isAscii(ch) ? 1 : 2); + +/** Wrap into <= maxLines lines of <= cols cells. Manual "\n" respected. */ +export function wrapText(text: string, cols = CAP_COLS, maxLines = CAP_LINES): string[] { + const out: string[] = []; + for (const para of text.split("\n")) { + let line = ""; + let w = 0; + // units: ASCII runs stay whole; CJK breaks anywhere + const units = para.match(/[\x21-\x7e]+| +|[^\x20-\x7e]/g) ?? []; + for (const u of units) { + const uw = [...u].reduce((n, c) => n + cellW(c), 0); + if (w + uw > cols && w > 0) { + out.push(line); + line = u === " " || /^ +$/.test(u) ? "" : u; + w = line ? uw : 0; + } else { + line += u; + w += uw; + } + } + out.push(line); + } + while (out.length && out[out.length - 1] === "") out.pop(); + if (out.length > maxLines) { + throw new Error(`caption overflows ${maxLines} lines: ${JSON.stringify(text)} -> ${JSON.stringify(out)}`); + } + return out; +} + +export class TextBank { + private ids = new Map(); + readonly entries: { raw: string; lines: string[] }[] = []; + /** fullwidth glyph interner: char -> glyph id */ + private glyphIds = new Map(); + readonly glyphChars: string[] = []; + + intern(text: string, opts: { cols?: number; maxLines?: number } = {}): number { + const key = text; + const hit = this.ids.get(key); + if (hit !== undefined) return hit; + const lines = wrapText(text, opts.cols ?? CAP_COLS, opts.maxLines ?? CAP_LINES); + const id = this.entries.length; + this.entries.push({ raw: text, lines }); + this.ids.set(key, id); + for (const line of lines) { + for (const ch of line) { + if (isAscii(ch)) continue; + if (!this.glyphIds.has(ch)) { + this.glyphIds.set(ch, this.glyphChars.length); + this.glyphChars.push(ch); + } + } + } + return id; + } + + tokenize(id: number): Uint8Array { + const w = new ByteWriter(); + const { lines } = this.entries[id]; + lines.forEach((line, i) => { + if (i > 0) w.u8(TOK_NL); + for (const ch of line) { + const cp = ch.codePointAt(0)!; + if (isAscii(ch)) { + w.u8(cp); + } else { + const gid = this.glyphIds.get(ch); + if (gid === undefined) throw new Error(`glyph not interned: ${ch}`); + w.u8(0x80 | ((gid >> 8) & 0x7f)).u8(gid & 0xff); + } + } + }); + w.u8(TOK_END); + return w.toUint8Array(); + } + + /** Glyph store: 95 ASCII halfcells + 2 halfcells per fullwidth glyph. + * Each halfcell = two stacked 4bpp 8x8 tiles (64 bytes), ink/bg indices. */ + bakeGlyphStore(ink: number, bg: number): Uint8Array { + const halfcells: Uint8Array[] = []; + const pack = (px: number[]): Uint8Array => { + const t = new Uint8Array(32); + for (let row = 0; row < 8; row++) + for (let c = 0; c < 4; c++) { + const lo = px[row * 8 + c * 2] & 0xf; + const hi = px[row * 8 + c * 2 + 1] & 0xf; + t[row * 4 + c] = lo | (hi << 4); + } + return t; + }; + const bake = (cp: number | null, half: 0 | 1): Uint8Array => { + const glyph = cp === null ? null : unifontGlyph(cp); + const [top, bottom] = halfcellPixels(glyph, half, ink, bg); + const out = new Uint8Array(64); + out.set(pack(top), 0); + out.set(pack(bottom), 32); + return out; + }; + for (let cp = 0x20; cp <= 0x7e; cp++) halfcells.push(bake(cp, 0)); + for (const ch of this.glyphChars) { + const cp = ch.codePointAt(0)!; + const g = unifontGlyph(cp); + if (g && g.width === 8) { + halfcells.push(bake(cp, 0), bake(null, 0)); // halfwidth glyph + blank right half + } else { + halfcells.push(bake(cp, 0), bake(cp, 1)); + } + } + const blob = new Uint8Array(halfcells.length * 64); + halfcells.forEach((hc, i) => blob.set(hc, i * 64)); + return blob; + } + + buildBlob(): { offs: number[]; blob: Uint8Array } { + const offs: number[] = []; + const parts: Uint8Array[] = []; + let cur = 0; + for (let i = 0; i < this.entries.length; i++) { + const t = this.tokenize(i); + offs.push(cur); + parts.push(t); + cur += t.length; + } + const blob = new Uint8Array(cur); + let o = 0; + for (const p of parts) { + blob.set(p, o); + o += p.length; + } + return { offs, blob }; + } +} diff --git a/cine/dsl/index.ts b/cine/dsl/index.ts new file mode 100644 index 0000000..6d7698e --- /dev/null +++ b/cine/dsl/index.ts @@ -0,0 +1,174 @@ +// cine/dsl/index.ts — the @pocketjs/cine authoring surface. +// +// Two zones, same discipline as @pocketjs/aot: +// - the DECLARATION zone (defineFilm/defineScene/image/gradient/sprite) is +// executed at build time and fills a registry; +// - the RESIDUAL zone (`play: cue(function* () { ... })`) is never executed — +// the compiler lowers the generator's AST to cue bytecode. The op functions +// exported here are compile-time vocabulary; calling them outside a cue() +// body is an authoring error. + +export type Ease = "linear" | "in" | "out" | "inout"; +export type CaptionStyle = "chip" | "sub" | "card"; + +export interface GradientDecl { + kind: "gradient"; + stops: string[]; +} +export interface LayerDecl { + kind: "image"; + png: string; + scroll?: number; // parallax factor vs camera (default: far .4, sky .15) + vx?: number; // autoscroll px/frame (fractions fine) + wide?: boolean; // main only: image wider than 240 -> 64x32 map + y?: number; // vertical placement offset in px (multiple of 8) +} +export interface ActorDecl { + png: string; + w: number; + h: number; + frames?: number; // horizontal strip + fps?: number; // anim frame period (frames per cell) + at?: [number, number]; + show?: boolean; + flip?: boolean; + ghost?: boolean; // OBJ semi-transparency + behind?: boolean; // prio 3, behind the main stage + screen?: boolean; // screen-space (HUD-like) +} + +export interface SceneDecl { + id: string; + sky?: GradientDecl | LayerDecl; + far?: LayerDecl; + main?: LayerDecl; + backdrop?: string; // hex color when no gradient (default #000000) + camera?: { start?: number; min?: number; max?: number }; + letterbox?: number; // initial bar height px + wave?: { layer: "main" | "far"; amp: number }; // raster sine on from scene start + actors?: Record; + play: CueRef; +} + +export interface FilmDecl { + title: string; + scenes: SceneDecl[]; +} + +export interface CueRef { + __cue: number; +} + +export interface Registry { + film: FilmDecl | null; + scenes: SceneDecl[]; +} + +const REGISTRY: Registry = { film: null, scenes: [] }; + +export function __getRegistry(): Registry { + return REGISTRY; +} +export function __resetRegistry(): void { + REGISTRY.film = null; + REGISTRY.scenes = []; +} + +// --- declaration zone --------------------------------------------------------- + +export function defineScene(decl: SceneDecl): SceneDecl { + REGISTRY.scenes.push(decl); + return decl; +} + +export function defineFilm(decl: FilmDecl): FilmDecl { + if (REGISTRY.film) throw new Error("defineFilm() called twice"); + REGISTRY.film = decl; + return decl; +} + +export function image(png: string, opts: Omit = {}): LayerDecl { + return { kind: "image", png, ...opts }; +} + +export function gradient(...stops: string[]): GradientDecl { + if (stops.length < 2) throw new Error("gradient() needs at least 2 stops"); + return { kind: "gradient", stops }; +} + +export function sprite(png: string, opts: Omit): ActorDecl { + return { png, ...opts }; +} + +/** Residual generator marker. The compiler replaces the argument with an id. */ +export function cue(fn: number | (() => Generator)): CueRef { + if (typeof fn === "number") return { __cue: fn }; + throw new Error("cue() bodies are residual-only; compile with cine/compiler"); +} + +// --- residual zone vocabulary --------------------------------------------------- +// Every function below may ONLY appear inside cue(function* () { ... }) as +// `yield op(...)` (or in if/while conditions where noted). Bodies never run. + +const residual = (name: string) => (): never => { + throw new Error(`${name}() is residual-only — use it inside cue(function* () {...})`); +}; + +type R = unknown; + +// blocking +export const fadeIn = residual("fadeIn") as (frames?: number, color?: "black" | "white") => R; +export const fadeOut = residual("fadeOut") as (frames?: number, color?: "black" | "white") => R; +export const wait = residual("wait") as (frames: number) => R; +export const waitA = residual("waitA") as () => R; +export const waitTweens = residual("waitTweens") as () => R; +export const caption = residual("caption") as (style: CaptionStyle, text: string) => R; +export const dialog = residual("dialog") as (speaker: string, text: string) => R; +export const choice = residual("choice") as (options: string[]) => number; +export const walkTo = residual("walkTo") as (actor: string, x: number, frames: number) => R; +export const control = residual("control") as (actor: string, exitX: number, speed?: number) => R; +export const mash = residual("mash") as (varName: string, target: number) => R; + +// non-blocking +export const captionClear = residual("captionClear") as (style?: CaptionStyle | "all") => R; +export const pan = residual("pan") as (x: number, frames: number, ease?: Ease) => R; +export const panY = residual("panY") as (y: number, frames: number, ease?: Ease) => R; +export const alpha = residual("alpha") as (eva: number, evb: number, frames: number) => R; +export const mosaicTo = residual("mosaicTo") as (level: number, frames: number) => R; +export const shake = residual("shake") as (amp: number, frames: number) => R; +export const autoScroll = residual("autoScroll") as (layer: "far" | "sky", vx: number, frames?: number) => R; +export const zoom = residual("zoom") as (scale: number, frames: number, ease?: Ease) => R; +export const spinTo = residual("spinTo") as (angle: number, frames: number, ease?: Ease) => R; +export const letterbox = residual("letterbox") as (px: number, frames?: number) => R; +export const rasterWave = residual("rasterWave") as (layer: "main" | "far", amp: number) => R; +export const rasterGradient = residual("rasterGradient") as () => R; +export const rasterOff = residual("rasterOff") as () => R; +export const show = residual("show") as ( + actor: string, + x?: number, + y?: number, + opts?: { flip?: boolean }, +) => R; +export const hide = residual("hide") as (actor: string) => R; +export const animate = residual("animate") as (actor: string, mode: "loop" | number, fps?: number) => R; +export const moveTo = residual("moveTo") as (actor: string, x: number, y: number, frames: number, ease?: Ease) => R; +export const affineOn = residual("affineOn") as (actor: string) => R; +export const affineOff = residual("affineOff") as (actor: string) => R; +export const counter = residual("counter") as (varName: string, x: number, y: number) => R; +export const counterHide = residual("counterHide") as () => R; +export const sfx = residual("sfx") as (id: "blip" | "confirm" | "whoosh" | "star") => R; +export const gotoScene = residual("gotoScene") as (sceneId: string) => R; + +// state (usable in conditions) +export const setFlag = residual("setFlag") as (name: string) => R; +export const clrFlag = residual("clrFlag") as (name: string) => R; +export const hasFlag = residual("hasFlag") as (name: string) => number; +export const setVar = residual("setVar") as (name: string, v: number) => R; +export const addVar = residual("addVar") as (name: string, d: number) => R; +export const varEq = residual("varEq") as (name: string, v: number) => number; +export const varNe = residual("varNe") as (name: string, v: number) => number; +export const varLt = residual("varLt") as (name: string, v: number) => number; +export const varGt = residual("varGt") as (name: string, v: number) => number; +export const varLe = residual("varLe") as (name: string, v: number) => number; +export const varGe = residual("varGe") as (name: string, v: number) => number; +export const rnd = residual("rnd") as (n: number) => number; diff --git a/cine/film/art/bg_classroom.png b/cine/film/art/bg_classroom.png new file mode 100644 index 0000000..5e02826 Binary files /dev/null and b/cine/film/art/bg_classroom.png differ diff --git a/cine/film/art/bg_deskstorm.png b/cine/film/art/bg_deskstorm.png new file mode 100644 index 0000000..48ea663 Binary files /dev/null and b/cine/film/art/bg_deskstorm.png differ diff --git a/cine/film/art/bg_harbor.png b/cine/film/art/bg_harbor.png new file mode 100644 index 0000000..b32d629 Binary files /dev/null and b/cine/film/art/bg_harbor.png differ diff --git a/cine/film/art/bg_hill.png b/cine/film/art/bg_hill.png new file mode 100644 index 0000000..b5408cd Binary files /dev/null and b/cine/film/art/bg_hill.png differ diff --git a/cine/film/art/bg_kitchen.png b/cine/film/art/bg_kitchen.png new file mode 100644 index 0000000..e4db614 Binary files /dev/null and b/cine/film/art/bg_kitchen.png differ diff --git a/cine/film/art/bg_nyc.png b/cine/film/art/bg_nyc.png new file mode 100644 index 0000000..4de4de3 Binary files /dev/null and b/cine/film/art/bg_nyc.png differ diff --git a/cine/film/art/bg_office.png b/cine/film/art/bg_office.png new file mode 100644 index 0000000..88ec8b6 Binary files /dev/null and b/cine/film/art/bg_office.png differ diff --git a/cine/film/art/bg_room90s.png b/cine/film/art/bg_room90s.png new file mode 100644 index 0000000..73a1288 Binary files /dev/null and b/cine/film/art/bg_room90s.png differ diff --git a/cine/film/art/bg_stage.png b/cine/film/art/bg_stage.png new file mode 100644 index 0000000..62a43d7 Binary files /dev/null and b/cine/film/art/bg_stage.png differ diff --git a/cine/film/art/far_skyline.png b/cine/film/art/far_skyline.png new file mode 100644 index 0000000..f63c577 Binary files /dev/null and b/cine/film/art/far_skyline.png differ diff --git a/cine/film/art/far_waves.png b/cine/film/art/far_waves.png new file mode 100644 index 0000000..11ba90b Binary files /dev/null and b/cine/film/art/far_waves.png differ diff --git a/cine/film/art/manifest.json b/cine/film/art/manifest.json new file mode 100644 index 0000000..2aa0840 --- /dev/null +++ b/cine/film/art/manifest.json @@ -0,0 +1,152 @@ +{ + "bg_room90s": { + "prompt": "1990s Chinese apartment interior at dusk, side view: wooden desk with a beige retro computer and boxy CRT monitor on the right, bookshelf and posters on the wall, window with distant water-town rooftops on the left, warm lamp light, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42001, + "w": 240, + "h": 160 + }, + "bg_classroom": { + "prompt": "early-2000s classroom interior, side view: rows of desks in silhouette at the bottom, a large bright projector screen glowing pale blue on the front wall, chalkboard edge, dark room lit by the screen, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42002, + "w": 240, + "h": 160 + }, + "bg_nyc": { + "prompt": "rainy city night panorama, side view: on the left a lit bus stop with a bench, in the middle dark brownstone buildings with fire escapes, on the right a dorm room window glowing warm with a desk lamp and a small desk visible inside, wet street reflections, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42003, + "w": 384, + "h": 160 + }, + "bg_office": { + "prompt": "modern tech office at night, side view: a long desk with two glowing monitors, one desk lamp on, big windows behind showing a city skyline with tiny lights, empty chairs, blue-dark ambience with one warm pool of light, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42004, + "w": 240, + "h": 160 + }, + "bg_kitchen": { + "prompt": "small cozy apartment kitchen in the evening, side view: a wooden table with two chairs in the center, kettle on the stove, fridge with sticky notes, warm yellow light from a ceiling lamp, window with dark blue night outside, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42005, + "w": 240, + "h": 160 + }, + "bg_stage": { + "prompt": "dark conference stage, side view: a wide glowing presentation screen high on the back wall, a small podium on the right, two spotlight beams, front rows of audience heads as dark silhouettes at the bottom, teal and dark blue palette, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42006, + "w": 240, + "h": 160 + }, + "bg_deskstorm": { + "prompt": "home office at night during a thunderstorm, side view: a desk with a bright monitor in a dark room, a large window behind it streaked with rain, storm clouds and a flash of lightning outside, papers on the floor, a small toy on the shelf, cold blue palette with one bright screen, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42007, + "w": 240, + "h": 160 + }, + "bg_harbor": { + "prompt": "calm sea at dawn panorama, side view: open water with gentle waves in the foreground, a distant harbor on the right horizon with orange cranes and warehouses catching the sunrise, soft pink and gold sky low over the waterline, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42008, + "w": 384, + "h": 160 + }, + "bg_hill": { + "prompt": "grassy hill at sunset, side view: the hill crest as a dark green silhouette across the lower third, one small tree on the left, a tiny city skyline far below on the right edge, huge warm gradient sky taking most of the frame, a few first stars, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42009, + "w": 240, + "h": 160 + }, + "far_skyline": { + "prompt": "distant city skyline silhouette at dusk, flat dark navy building shapes with a few tiny lit windows, skyline occupies the bottom half, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42010, + "w": 240, + "h": 80 + }, + "far_waves": { + "prompt": "stylized dark teal sea wave strip, repeating rolling wave crests with lighter foam tips, seen from the side, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42011, + "w": 240, + "h": 48 + }, + "spr_kid": { + "prompt": "one small boy in a 1990s red jacket and blue pants, black hair, standing side profile facing right, full body, single character, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42020, + "w": 32, + "h": 32 + }, + "spr_evan": { + "prompt": "one young man with short black hair and glasses, dark green hoodie, jeans, standing side profile facing right, full body, single character, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42021, + "w": 32, + "h": 32 + }, + "spr_dad": { + "prompt": "one middle-aged man in a plain 1990s shirt and trousers, short black hair, standing side profile facing left, full body, single character, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42022, + "w": 32, + "h": 32 + }, + "spr_wife": { + "prompt": "full body tiny sprite of one young woman standing, head to toe visible, shoulder-length black hair, warm beige cardigan and long skirt, side profile facing left, feet at the bottom, single small character, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 52023, + "w": 32, + "h": 32 + }, + "spr_child": { + "prompt": "one toddler in yellow overalls, tiny, standing side profile facing right, full body, single character, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42024, + "w": 32, + "h": 32 + }, + "spr_fish": { + "prompt": "one tropical fish swimming right, orange and white, simple cute shape, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42025, + "w": 32, + "h": 32 + }, + "spr_letter": { + "prompt": "one white paper envelope with a red and blue airmail border, slightly tilted, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42026, + "w": 32, + "h": 32 + }, + "spr_bolt": { + "prompt": "one stylized lightning bolt gem, indigo purple outer glow with a golden amber core, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42028, + "w": 32, + "h": 32 + }, + "spr_ship": { + "prompt": "one small dark sailing ship with a bright green triangular sail, side profile facing right, floating, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42029, + "w": 64, + "h": 32 + }, + "spr_flag": { + "prompt": "one rectangular dark navy fabric flag waving to the right from a tall vertical wooden flagpole on the left edge, cloth rippling in the wind, small white circle emblem on the cloth, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 52030, + "w": 32, + "h": 32 + }, + "spr_book": { + "prompt": "one thick closed textbook with a grey-green cover, side view, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42031, + "w": 32, + "h": 32 + }, + "spr_orb": { + "prompt": "one small glowing round orb, soft cyan core with white highlight, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42032, + "w": 32, + "h": 32 + }, + "spr_star": { + "prompt": "one small four-pointed sparkle star, bright green-white, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42033, + "w": 32, + "h": 32 + }, + "spr_drawing": { + "prompt": "a child's simple drawing of a house and a sun rendered in bright pixels on a dark blue computer screen rectangle, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 42034, + "w": 32, + "h": 32 + } +} \ No newline at end of file diff --git a/cine/film/art/spr_bolt.png b/cine/film/art/spr_bolt.png new file mode 100644 index 0000000..ebabe97 Binary files /dev/null and b/cine/film/art/spr_bolt.png differ diff --git a/cine/film/art/spr_book.png b/cine/film/art/spr_book.png new file mode 100644 index 0000000..4fd9aad Binary files /dev/null and b/cine/film/art/spr_book.png differ diff --git a/cine/film/art/spr_child.png b/cine/film/art/spr_child.png new file mode 100644 index 0000000..2124c72 Binary files /dev/null and b/cine/film/art/spr_child.png differ diff --git a/cine/film/art/spr_dad.png b/cine/film/art/spr_dad.png new file mode 100644 index 0000000..69e2b13 Binary files /dev/null and b/cine/film/art/spr_dad.png differ diff --git a/cine/film/art/spr_drawing.png b/cine/film/art/spr_drawing.png new file mode 100644 index 0000000..ad9152c Binary files /dev/null and b/cine/film/art/spr_drawing.png differ diff --git a/cine/film/art/spr_evan.png b/cine/film/art/spr_evan.png new file mode 100644 index 0000000..2ea1f21 Binary files /dev/null and b/cine/film/art/spr_evan.png differ diff --git a/cine/film/art/spr_fish.png b/cine/film/art/spr_fish.png new file mode 100644 index 0000000..ffe6e27 Binary files /dev/null and b/cine/film/art/spr_fish.png differ diff --git a/cine/film/art/spr_flag.png b/cine/film/art/spr_flag.png new file mode 100644 index 0000000..04cc5b1 Binary files /dev/null and b/cine/film/art/spr_flag.png differ diff --git a/cine/film/art/spr_kid.png b/cine/film/art/spr_kid.png new file mode 100644 index 0000000..110baa0 Binary files /dev/null and b/cine/film/art/spr_kid.png differ diff --git a/cine/film/art/spr_letter.png b/cine/film/art/spr_letter.png new file mode 100644 index 0000000..eda37c4 Binary files /dev/null and b/cine/film/art/spr_letter.png differ diff --git a/cine/film/art/spr_orb.png b/cine/film/art/spr_orb.png new file mode 100644 index 0000000..c120327 Binary files /dev/null and b/cine/film/art/spr_orb.png differ diff --git a/cine/film/art/spr_ship.png b/cine/film/art/spr_ship.png new file mode 100644 index 0000000..5f34136 Binary files /dev/null and b/cine/film/art/spr_ship.png differ diff --git a/cine/film/art/spr_star.png b/cine/film/art/spr_star.png new file mode 100644 index 0000000..6248802 Binary files /dev/null and b/cine/film/art/spr_star.png differ diff --git a/cine/film/art/spr_vuelogo.png b/cine/film/art/spr_vuelogo.png new file mode 100644 index 0000000..17f284b Binary files /dev/null and b/cine/film/art/spr_vuelogo.png differ diff --git a/cine/film/art/spr_wife.png b/cine/film/art/spr_wife.png new file mode 100644 index 0000000..10c48fc Binary files /dev/null and b/cine/film/art/spr_wife.png differ diff --git a/cine/film/bake-logo.ts b/cine/film/bake-logo.ts new file mode 100644 index 0000000..732760f --- /dev/null +++ b/cine/film/bake-logo.ts @@ -0,0 +1,74 @@ +// cine/film/bake-logo.ts — rasterize the Vue logo from its SVG path geometry +// into a native 64x64 sprite (film/art/spr_vuelogo.png). +// +// The logo is two nested polygons (official vuejs.org SVG, viewBox +// 0 0 261.76 226.69): the outer V in Vue green over the inner V in slate. +// We scanline-fill with 4x4 supersampling and snap to the two flat colors — +// crisp pixel art, no anti-aliasing, index-friendly for the 4bpp quantizer. +// +// bun film/bake-logo.ts + +import { encodePng } from "../compiler/png.ts"; + +type Pt = [number, number]; +// path d="M161.096.001 130.871 52.352 100.647.001H-.005L130.877 226.688 261.749.001z" +const OUTER: Pt[] = [ + [161.096, 0.001], [130.871, 52.352], [100.647, 0.001], [-0.005, 0.001], + [130.877, 226.688], [261.749, 0.001], +]; +// path d="M161.096.001 130.871 52.352 100.647.001H52.346l78.526 136.01L209.398.001z" +const INNER: Pt[] = [ + [161.096, 0.001], [130.871, 52.352], [100.647, 0.001], [52.346, 0.001], + [130.872, 136.011], [209.398, 0.001], +]; + +const GREEN: [number, number, number] = [0x41, 0xb8, 0x83]; +const SLATE: [number, number, number] = [0x35, 0x49, 0x5e]; + +function inside(p: Pt[], x: number, y: number): boolean { + let hit = false; + for (let i = 0, j = p.length - 1; i < p.length; j = i++) { + const [xi, yi] = p[i]; + const [xj, yj] = p[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; + } + return hit; +} + +const SIZE = 64; +const VB_W = 261.76; +const VB_H = 226.69; +const scale = (SIZE - 4) / VB_W; // 2px margin each side +const drawnH = VB_H * scale; +const ox = (SIZE - VB_W * scale) / 2; +const oy = (SIZE - drawnH) / 2; + +const SS = 4; // supersamples per axis +const rgba = new Uint8Array(SIZE * SIZE * 4); +for (let py = 0; py < SIZE; py++) { + for (let px = 0; px < SIZE; px++) { + let nInner = 0; + let nOuterOnly = 0; + for (let sy = 0; sy < SS; sy++) { + for (let sx = 0; sx < SS; sx++) { + const x = (px + (sx + 0.5) / SS - ox) / scale; + const y = (py + (sy + 0.5) / SS - oy) / scale; + if (inside(INNER, x, y)) nInner++; + else if (inside(OUTER, x, y)) nOuterOnly++; + } + } + const covered = nInner + nOuterOnly; + if (covered >= (SS * SS) / 2) { + const c = nInner >= nOuterOnly ? SLATE : GREEN; + const i = (py * SIZE + px) * 4; + rgba[i] = c[0]; + rgba[i + 1] = c[1]; + rgba[i + 2] = c[2]; + rgba[i + 3] = 255; + } + } +} + +const out = new URL("./art/spr_vuelogo.png", import.meta.url).pathname; +await Bun.write(out, encodePng(rgba, SIZE, SIZE)); +console.log(`baked ${out} (${SIZE}x${SIZE}, green/slate from SVG geometry)`); diff --git a/cine/film/progressive-life.ts b/cine/film/progressive-life.ts new file mode 100644 index 0000000..8b8c6e9 --- /dev/null +++ b/cine/film/progressive-life.ts @@ -0,0 +1,566 @@ +// 《渐进人生 · A PROGRESSIVE LIFE》 +// +// An interactive pixel-art life montage of 尤雨溪 (Evan You) — creator of +// Vue.js and Vite — in the spirit of the opening montage of "Up". +// A fan tribute (同人致敬). All dialogue is original writing; events follow +// public interviews and first-party posts. Not affiliated with anyone. +// +// bun compiler/cli.ts build film/progressive-life.ts --out dist/progressive-life.gba +// +// GBA feature bingo, on purpose: per-scanline gradient skies (HBlank raster), +// 64x32 wide pans, parallax + autoscroll, WIN0 letterbox, mosaic, white/black +// BLDY fades, ghost alpha, affine zoom/spin emblems, wave raster, typewriter +// CJK captions, PSG blips, and playable beats in every scene. + +import { + defineFilm, defineScene, cue, image, gradient, sprite, + fadeIn, fadeOut, wait, waitA, waitTweens, caption, captionClear, dialog, choice, + pan, letterbox, mosaicTo, shake, alpha, zoom, spinTo, show, hide, animate, + moveTo, walkTo, control, mash, counter, counterHide, affineOn, affineOff, sfx, + gotoScene, setVar, varEq, rasterWave, rasterOff, +} from "@pocketjs/cine"; + +// --------------------------------------------------------------------------- +// 0 · 标题 — chapter select boots straight in (真机验收要求) +// --------------------------------------------------------------------------- +const title = defineScene({ + id: "title", + sky: gradient("#0b1f24", "#123832", "#2a6b4f", "#0b1f24"), + far: image("art/far_skyline.png", { scroll: 0.3, y: 80 }), + backdrop: "#0b1f24", + actors: { + vlogo: sprite("art/spr_vuelogo.png", { w: 64, h: 64, screen: true }), + }, + play: cue(function* () { + yield fadeIn(40); + yield show("vlogo", 120, 32); + yield affineOn("vlogo"); + yield zoom(0.2, 1); + yield spinTo(360, 80, "out"); + yield zoom(1.0, 80, "out"); + yield caption("card", "渐进人生"); + yield wait(30); + yield caption("sub", "A PROGRESSIVE LIFE\n同人致敬 · 非官方作品"); + yield waitTweens(); + yield wait(40); + yield captionClear("sub"); + while (yield varEq("nav", 0)) { + const m = yield choice(["从头播放", "选择章节"]); + if (m === 0) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(30); + yield gotoScene("paint486"); + } + const p = yield choice(["486与画笔", "水族馆", "一个URL", "取名那晚", "后面的章节…"]); + if (p === 0) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(30); + yield gotoScene("paint486"); + } + if (p === 1) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(30); + yield gotoScene("aquarium"); + } + if (p === 2) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(30); + yield gotoScene("nyc"); + } + if (p === 3) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(30); + yield gotoScene("seed"); + } + const q = yield choice(["那封信", "星星", "OnePiece与闪电", "启航", "山丘与片尾"]); + if (q === 0) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(30); + yield gotoScene("letter"); + } + if (q === 1) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(30); + yield gotoScene("stars"); + } + if (q === 2) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(30); + yield gotoScene("onepiece"); + } + if (q === 3) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(30); + yield gotoScene("fleet"); + } + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(30); + yield gotoScene("coda"); + } + }), +}); + +// --------------------------------------------------------------------------- +// 1 · 486 与画笔 — 无锡,1990年代 +// --------------------------------------------------------------------------- +const paint486 = defineScene({ + id: "paint486", + main: image("art/bg_room90s.png"), + backdrop: "#000000", + actors: { + kid: sprite("art/spr_kid.png", { w: 32, h: 32, at: [28, 100] }), + dad: sprite("art/spr_dad.png", { w: 32, h: 32, at: [120, 98] }), + drawing: sprite("art/spr_drawing.png", { w: 32, h: 32, screen: true }), + }, + play: cue(function* () { + yield setVar("nav", 0); + yield letterbox(12, 1); + yield fadeIn(45); + yield caption("chip", "无锡 · 1990年代"); + yield wait(20); + yield show("dad"); + yield show("kid"); + yield letterbox(0, 30); + yield dialog("父亲", "搬回来的时候他说:\n以后,计算机就是未来。"); + yield hide("dad"); + yield caption("sub", "←→ 走到那台 486 前"); + yield control("kid", 168, 1.2); + yield captionClear("all"); + yield dialog("少年", "(按下电源。)"); + yield show("drawing", 192, 60); + yield sfx("star"); + yield zoom(1.0, 1); + yield wait(20); + yield caption("sub", "他没有先学会写程序,\n他先学会了画画。"); + yield waitA(); + yield captionClear("all"); + yield hide("drawing"); + yield mosaicTo(10, 40); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------- +// 2 · 水族馆 — 上海,高中 +// --------------------------------------------------------------------------- +const aquarium = defineScene({ + id: "aquarium", + main: image("art/bg_classroom.png"), + backdrop: "#000000", + actors: { + fish1: sprite("art/spr_fish.png", { w: 32, h: 32 }), + fish2: sprite("art/spr_fish.png", { w: 32, h: 32 }), + fish3: sprite("art/spr_fish.png", { w: 32, h: 32 }), + }, + play: cue(function* () { + yield setVar("nav", 0); + yield fadeIn(40); + yield caption("chip", "上海 · 高中机房"); + yield wait(10); + yield caption("sub", "语文课代表没想到,\n他交上来的是一座水族馆。"); + yield waitA(); + yield captionClear("all"); + yield rasterWave("main", 2); + yield sfx("whoosh"); + yield show("fish1", -40, 34); + yield show("fish2", -70, 52); + yield show("fish3", -100, 68); + yield moveTo("fish1", 260, 30, 240, "linear"); + yield moveTo("fish2", 260, 56, 300, "linear"); + yield moveTo("fish3", 260, 66, 360, "linear"); + yield wait(90); + yield dialog("全班", "哇——"); + yield shake(2, 30); + yield wait(60); + yield rasterOff(); + yield caption("sub", "他一直记得的,\n是大家抬起头时的表情。"); + yield waitA(); + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------- +// 3 · 一个 URL — 纽约,2011 +// --------------------------------------------------------------------------- +const nyc = defineScene({ + id: "nyc", + main: image("art/bg_nyc.png"), + backdrop: "#000000", + actors: { + evan: sprite("art/spr_evan.png", { w: 32, h: 32, at: [30, 106] }), + book: sprite("art/spr_book.png", { w: 32, h: 32 }), + }, + play: cue(function* () { + yield setVar("nav", 0); + yield letterbox(12, 1); + yield fadeIn(45); + yield caption("chip", "纽约 · 2011"); + yield wait(20); + yield show("evan"); + yield show("book", 44, 94); + yield caption("sub", "45 分钟的公交车,\n一本厚厚的 JavaScript。"); + yield waitA(); + yield captionClear("sub"); + yield hide("book"); + yield letterbox(0, 30); + yield caption("sub", "←→ 穿过雨夜,回到宿舍"); + yield control("evan", 330, 1.4); + yield captionClear("all"); + yield caption("sub", "半夜,他把一个小实验\n放上了网。"); + yield waitA(); + yield captionClear("sub"); + yield setVar("hn", 88); + yield counter("hn", 210, 26); + yield caption("sub", "连按 A —— 它正在上首页!"); + yield mash("hn", 100); + yield sfx("confirm"); + yield captionClear("all"); + yield wait(20); + yield caption("sub", "一个 URL,可以寄给\n世界上任何一个人。"); + yield waitA(); + yield captionClear("all"); + yield counterHide(); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------- +// 4 · 取名那晚 — 2013 → 2014.2 +// --------------------------------------------------------------------------- +const seed = defineScene({ + id: "seed", + main: image("art/bg_office.png"), + backdrop: "#000000", + actors: { + evan: sprite("art/spr_evan.png", { w: 32, h: 32, at: [96, 104] }), + }, + play: cue(function* () { + yield setVar("nav", 0); + yield fadeIn(40); + yield caption("chip", "谷歌 · 2013"); + yield wait(10); + yield show("evan"); + yield caption("sub", "白天做原型;晚上只想留下\n自己最喜欢的那一小部分。"); + yield waitA(); + yield captionClear("all"); + yield dialog("npm", "错误:seed 这个名字\n已经被占用了。"); + const c = yield choice(["View", "Vue", "Vista"]); + if (c === 1) { + yield dialog("他", "法语里的 View。\n就它了。"); + } else { + yield dialog("他", "嗯……不如换成法语?\nVue。就它了。"); + } + yield captionClear("all"); + yield caption("chip", "2014年2月 · Hacker News"); + yield setVar("stars", 0); + yield counter("stars", 210, 26); + yield caption("sub", "连按 A —— 第一批星星!"); + yield mash("stars", 16); + yield sfx("confirm"); + yield captionClear("all"); + yield caption("sub", "第一周,几百颗星。\n真的有人在用它。"); + yield waitA(); + yield captionClear("all"); + yield counterHide(); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------- +// 5 · 那封信 — 新泽西,2016年2月(全片的核心) +// --------------------------------------------------------------------------- +const letter = defineScene({ + id: "letter", + main: image("art/bg_kitchen.png"), + backdrop: "#000000", + actors: { + evan: sprite("art/spr_evan.png", { w: 32, h: 32, at: [66, 104] }), + wife: sprite("art/spr_wife.png", { w: 32, h: 32, at: [156, 104] }), + mail: sprite("art/spr_letter.png", { w: 32, h: 32, screen: true }), + }, + play: cue(function* () { + yield setVar("nav", 0); + yield setVar("jumped", 0); + yield letterbox(14, 1); + yield fadeIn(60); + yield caption("chip", "新泽西 · 2016年2月"); + yield wait(20); + yield show("evan"); + yield show("wife"); + yield caption("sub", "他想辞掉工作,全职做开源。\nPatreon:每月四千美元。"); + yield waitA(); + yield captionClear("sub"); + yield show("mail", 104, 64); + yield affineOn("mail"); + yield zoom(0.4, 1); + yield zoom(1.6, 70, "out"); + yield sfx("blip"); + yield dialog("信箱", "一封保险续保信。\n她这才知道了这个计划。"); + yield affineOff("mail"); + yield hide("mail"); + yield dialog("妻子", "……去试吧。"); + yield dialog("妻子", "六个月。不行的话,\n我就把你踢回大厂上班。"); + while (yield varEq("jumped", 0)) { + const j = yield choice(["跳", "再想想"]); + if (j === 0) { + yield setVar("jumped", 1); + } else { + yield dialog("他", "(窗外,雪停了。)"); + yield caption("sub", "有些事现在不试,\n会想一辈子。"); + yield waitA(); + yield captionClear("sub"); + } + } + yield sfx("whoosh"); + yield shake(2, 30); + yield caption("sub", "他跳了。"); + yield wait(50); + yield captionClear("all"); + yield fadeOut(50, "white"); + }), +}); + +// --------------------------------------------------------------------------- +// 6 · 星星 — 2016 → 2018.6.16 +// --------------------------------------------------------------------------- +const stars = defineScene({ + id: "stars", + main: image("art/bg_stage.png"), + backdrop: "#000000", + actors: { + evan: sprite("art/spr_evan.png", { w: 32, h: 32, at: [150, 78] }), + star1: sprite("art/spr_star.png", { w: 32, h: 32, ghost: true }), + star2: sprite("art/spr_star.png", { w: 32, h: 32, ghost: true }), + }, + play: cue(function* () { + yield setVar("nav", 0); + yield fadeIn(45, "white"); + yield caption("chip", "2016 → 2018"); + yield wait(10); + yield show("evan"); + yield caption("sub", "Vue 2.0。讲台越来越大,\n台下的人来自世界各地。"); + yield waitA(); + yield captionClear("all"); + yield show("star1", 60, 120); + yield show("star2", 180, 130); + yield moveTo("star1", 70, 20, 180, "out"); + yield moveTo("star2", 190, 10, 220, "out"); + yield caption("sub", "2018年6月16日。"); + yield wait(40); + yield dialog("社区", "Vue 的星星数,\n超过了 React!"); + yield shake(1, 20); + yield sfx("star"); + yield captionClear("all"); + yield caption("sub", "后来他说,从那天起,\n他反而不再盯着星星看了。"); + yield waitA(); + yield captionClear("all"); + yield hide("star1"); + yield hide("star2"); + yield caption("sub", "←→ 走下讲台"); + yield control("evan", 24, 1.2); + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------- +// 7 · One Piece — 2018 → 2020.9.18 +// --------------------------------------------------------------------------- +const onepiece = defineScene({ + id: "onepiece", + main: image("art/bg_deskstorm.png"), + backdrop: "#000000", + actors: { + flag: sprite("art/spr_flag.png", { w: 32, h: 32 }), + }, + play: cue(function* () { + yield setVar("nav", 0); + yield fadeIn(40); + yield caption("chip", "重写 Vue 3 · 两年"); + yield rasterWave("main", 2); + yield shake(1, 120); + yield caption("sub", "三十份 RFC,\n两千六百次提交。"); + yield waitA(); + yield captionClear("sub"); + yield dialog("风暴", "别动我们熟悉的写法!"); + yield dialog("他", "那就谁都不落下:\n旧的写法,永远保留。"); + yield rasterOff(); + yield caption("chip", "2020年9月18日"); + yield show("flag", 52, 150); + yield moveTo("flag", 52, 44, 100, "inout"); + yield sfx("confirm"); + yield waitTweens(); + yield caption("sub", "Vue 3 起航,代号:\nOne Piece。"); + yield waitA(); + yield captionClear("all"); + yield wait(10); + yield fadeOut(16, "white"); + }), +}); + +// --------------------------------------------------------------------------- +// 8 · 闪电 — 2020.4,Vite +// --------------------------------------------------------------------------- +const vite = defineScene({ + id: "vite", + sky: gradient("#1a1030", "#43307a", "#c98a4b", "#f5d08a"), + backdrop: "#1a1030", + actors: { + bolt: sprite("art/spr_bolt.png", { w: 32, h: 32, screen: true }), + orb1: sprite("art/spr_orb.png", { w: 32, h: 32, ghost: true, screen: true }), + orb2: sprite("art/spr_orb.png", { w: 32, h: 32, ghost: true, screen: true }), + orb3: sprite("art/spr_orb.png", { w: 32, h: 32, ghost: true, screen: true }), + orb4: sprite("art/spr_orb.png", { w: 32, h: 32, ghost: true, screen: true }), + }, + play: cue(function* () { + yield setVar("nav", 0); + yield fadeIn(24, "white"); + yield caption("chip", "2020年4月"); + yield show("bolt", 104, 44); + yield affineOn("bolt"); + yield zoom(0.3, 1); + yield zoom(1.5, 50, "out"); + yield spinTo(360, 70, "inout"); + yield sfx("whoosh"); + yield waitTweens(); + yield caption("sub", "同一个春天,他放出了\n一道法语的『快』:Vite。"); + yield waitA(); + yield captionClear("sub"); + yield caption("card", "npm create vite"); + yield wait(50); + yield captionClear("all"); + yield caption("sub", "冷启动:0.3 秒。"); + yield wait(60); + yield show("orb1", -20, 30); + yield show("orb2", 250, 40); + yield show("orb3", -20, 100); + yield show("orb4", 250, 110); + yield moveTo("orb1", 84, 52, 90, "inout"); + yield moveTo("orb2", 140, 52, 100, "inout"); + yield moveTo("orb3", 84, 84, 110, "inout"); + yield moveTo("orb4", 140, 84, 120, "inout"); + yield waitTweens(); + yield captionClear("sub"); + yield caption("sub", "一个个框架,\n都插上了这道闪电。"); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "有人管它叫:\nJavaScript 的联合国。"); + yield waitA(); + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------- +// 9 · 启航 — 新加坡,2024 → 2026 +// --------------------------------------------------------------------------- +const fleet = defineScene({ + id: "fleet", + main: image("art/bg_harbor.png"), + far: image("art/far_waves.png", { scroll: 0.5, y: 112, vx: -0.25 }), + backdrop: "#000000", + wave: { layer: "far", amp: 1 }, + actors: { + ship: sprite("art/spr_ship.png", { w: 64, h: 32 }), + }, + play: cue(function* () { + yield setVar("nav", 0); + yield letterbox(12, 1); + yield fadeIn(50); + yield caption("chip", "新加坡 · 2024"); + yield wait(20); + yield show("ship", -70, 84); + yield moveTo("ship", 150, 82, 300, "inout"); + yield pan(80, 300, "inout"); + yield caption("sub", "独木舟换成了一艘船:\nVoidZero。"); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "船员们用 Rust 打龙骨:\nRolldown,Oxc。"); + yield waitA(); + yield captionClear("sub"); + yield pan(144, 240, "inout"); + yield moveTo("ship", 260, 80, 260, "inout"); + yield caption("chip", "2026年6月"); + yield caption("sub", "船驶进了一座橙色的港湾。\n桅杆上的旗语没有换:"); + yield waitA(); + yield captionClear("all"); + yield caption("card", "OPEN & NEUTRAL"); + yield wait(80); + yield captionClear("all"); + yield caption("sub", "船还是那艘船,\n海图仍然对所有人公开。"); + yield waitA(); + yield captionClear("all"); + yield fadeOut(50); + }), +}); + +// --------------------------------------------------------------------------- +// 10 · 山丘与片尾 +// --------------------------------------------------------------------------- +const coda = defineScene({ + id: "coda", + main: image("art/bg_hill.png"), + backdrop: "#000000", + actors: { + evan: sprite("art/spr_evan.png", { w: 32, h: 32, at: [96, 76] }), + wife: sprite("art/spr_wife.png", { w: 32, h: 32, at: [130, 76] }), + kid1: sprite("art/spr_child.png", { w: 32, h: 32, at: [118, 82] }), + kid2: sprite("art/spr_child.png", { w: 32, h: 32, at: [148, 82], flip: true }), + vlogo: sprite("art/spr_vuelogo.png", { w: 64, h: 64, ghost: true, screen: true }), + }, + play: cue(function* () { + yield setVar("nav", 0); + yield letterbox(14, 1); + yield fadeIn(70); + yield show("evan"); + yield show("wife"); + yield show("kid1"); + yield show("kid2"); + yield wait(30); + yield caption("sub", "框架是渐进式的。\n人生,原来也是。"); + yield waitA(); + yield captionClear("sub"); + yield show("vlogo", 120, 28); + yield affineOn("vlogo"); + yield zoom(0.4, 1); + yield zoom(0.9, 120, "inout"); + yield spinTo(180, 240, "inout"); + yield caption("card", "0.9 Animatrix · 2014"); + yield wait(70); + yield caption("card", "1.0 Evangelion · 2015"); + yield wait(70); + yield caption("card", "2.0 Ghost in the Shell"); + yield wait(70); + yield caption("card", "3.0 One Piece · 2020"); + yield wait(70); + yield caption("card", "3.5 Gurren Lagann · 2024"); + yield wait(70); + yield caption("card", "U — ???"); + yield wait(50); + yield caption("sub", "下一集,还没有写完。"); + yield waitA(); + yield captionClear("all"); + yield caption("card", "渐进人生"); + yield caption("sub", "A FAN TRIBUTE · 2026\n谢谢你,尤大。"); + yield waitA(); + yield captionClear("all"); + yield fadeOut(70); + yield gotoScene("title"); + }), +}); + +export default defineFilm({ + title: "渐进人生 A PROGRESSIVE LIFE", + scenes: [title, paint486, aquarium, nyc, seed, letter, stars, onepiece, vite, fleet, coda], +}); diff --git a/cine/package.json b/cine/package.json new file mode 100644 index 0000000..d2a2f2b --- /dev/null +++ b/cine/package.json @@ -0,0 +1,24 @@ +{ + "name": "@pocketjs/cine", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "GBA cinematic-montage DSL: author interactive life-montage films in TypeScript, ship Mode-0 parallax, blending, raster FX and CJK typewriter captions as a real .gba ROM. Independent sibling of @pocketjs/aot.", + "exports": { + ".": "./dsl/index.ts", + "./compiler": "./compiler/index.ts", + "./spec": "./spec/cine.ts" + }, + "scripts": { + "gen": "bun spec/gen-c.ts", + "build": "bun compiler/cli.ts build film/progressive-life.ts --out dist/progressive-life.gba --title PROGLIFE", + "smoke": "bun test/gen-placeholder-art.ts && bun compiler/cli.ts build test/smoke-film.ts --out dist/smoke.gba --title CINESMOKE", + "art": "bun pixellab/generate.ts", + "test": "bun test/e2e.ts", + "play": "bash play.sh", + "bake:logo": "bun film/bake-logo.ts" + }, + "devDependencies": { + "typescript": "^5" + } +} \ No newline at end of file diff --git a/cine/pixellab/client.ts b/cine/pixellab/client.ts new file mode 100644 index 0000000..bf7e0df --- /dev/null +++ b/cine/pixellab/client.ts @@ -0,0 +1,77 @@ +// cine/pixellab/client.ts — thin typed client for the PixelLab API +// (https://api.pixellab.ai/v1). Bearer key comes from the repo-root .env +// (PIXELLAB_API_KEY). Endpoints used: generate-image-pixflux (up to 400x400), +// with retry/backoff; responses carry base64 PNGs. + +const API = "https://api.pixellab.ai/v1"; + +let cachedKey: string | null = null; + +export function apiKey(): string { + if (cachedKey) return cachedKey; + const envPath = new URL("../../.env", import.meta.url).pathname; + const text = require("node:fs").readFileSync(envPath, "utf8") as string; + const m = text.match(/^PIXELLAB_API_KEY=(.+)$/m); + if (!m) throw new Error("PIXELLAB_API_KEY not found in repo .env"); + cachedKey = m[1].trim(); + return cachedKey; +} + +export interface PixfluxOpts { + description: string; + width: number; + height: number; + negative?: string; + noBackground?: boolean; + outline?: "single color black outline" | "single color outline" | "selective outline" | "lineless"; + shading?: "flat shading" | "basic shading" | "medium shading" | "detailed shading" | "highly detailed shading"; + detail?: "low detail" | "medium detail" | "highly detailed"; + view?: "side" | "low top-down" | "high top-down"; + direction?: string; + textGuidance?: number; + seed?: number; + /** force palette: PNG bytes whose colors constrain the output */ + colorImage?: Uint8Array; +} + +async function post(path: string, body: unknown): Promise> { + let lastErr = ""; + for (let attempt = 0; attempt < 4; attempt++) { + const res = await fetch(API + path, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey()}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (res.ok) return (await res.json()) as Record; + lastErr = `${res.status} ${await res.text()}`; + if (res.status === 422 || res.status === 401) break; // no point retrying + await new Promise((r) => setTimeout(r, 2000 * (attempt + 1))); + } + throw new Error(`pixellab ${path}: ${lastErr}`); +} + +export async function pixflux(opts: PixfluxOpts): Promise { + const body: Record = { + description: opts.description, + image_size: { width: opts.width, height: opts.height }, + no_background: opts.noBackground ?? false, + text_guidance_scale: opts.textGuidance ?? 8, + }; + if (opts.negative) body.negative_description = opts.negative; + if (opts.outline) body.outline = opts.outline; + if (opts.shading) body.shading = opts.shading; + if (opts.detail) body.detail = opts.detail; + if (opts.view) body.view = opts.view; + if (opts.direction) body.direction = opts.direction; + if (opts.seed !== undefined) body.seed = opts.seed; + if (opts.colorImage) body.color_image = { type: "base64", base64: Buffer.from(opts.colorImage).toString("base64") }; + const res = await post("/generate-image-pixflux", body); + const img = res.image as { base64: string } | undefined; + if (!img?.base64) throw new Error("pixellab: no image in response"); + return new Uint8Array(Buffer.from(img.base64, "base64")); +} + +export async function balance(): Promise { + const res = await fetch(API + "/balance", { headers: { Authorization: `Bearer ${apiKey()}` } }); + return await res.text(); +} diff --git a/cine/pixellab/generate.ts b/cine/pixellab/generate.ts new file mode 100644 index 0000000..13a8441 --- /dev/null +++ b/cine/pixellab/generate.ts @@ -0,0 +1,375 @@ +// cine/pixellab/generate.ts — generate every film asset through PixelLab and +// cache it under film/art/ (committed, so builds never re-bill). +// bun pixellab/generate.ts [--force] [--only name[,name]] +// +// Prompts are deliberately franchise-neutral: scenes and emblems are described +// generically (a green gem shaped like a letter, a lightning gem, a sailing +// ship) — this is an original fan tribute, not asset cloning. + +import { pixflux, balance, type PixfluxOpts } from "./client.ts"; + +const OUT = new URL("../film/art/", import.meta.url).pathname; + +interface Spec extends Omit { + name: string; + w: number; + h: number; +} + +const PIXEL_STYLE = "clean 16-bit pixel art, limited palette, crisp pixels"; + +export const SPECS: Spec[] = [ + // --- backgrounds (main layers) --------------------------------------------- + { + name: "bg_room90s", + w: 240, + h: 160, + description: + `1990s Chinese apartment interior at dusk, side view: wooden desk with a beige retro computer and boxy CRT monitor on the right, bookshelf and posters on the wall, window with distant water-town rooftops on the left, warm lamp light, ${PIXEL_STYLE}`, + negative: "people, text, letters", + view: "side", + shading: "medium shading", + detail: "medium detail", + seed: 42001, + }, + { + name: "bg_classroom", + w: 240, + h: 160, + description: + `early-2000s classroom interior, side view: rows of desks in silhouette at the bottom, a large bright projector screen glowing pale blue on the front wall, chalkboard edge, dark room lit by the screen, ${PIXEL_STYLE}`, + negative: "people, text, letters", + view: "side", + shading: "medium shading", + detail: "medium detail", + seed: 42002, + }, + { + name: "bg_nyc", + w: 384, + h: 160, + description: + `rainy city night panorama, side view: on the left a lit bus stop with a bench, in the middle dark brownstone buildings with fire escapes, on the right a dorm room window glowing warm with a desk lamp and a small desk visible inside, wet street reflections, ${PIXEL_STYLE}`, + negative: "people, text, letters", + view: "side", + shading: "medium shading", + detail: "medium detail", + seed: 42003, + }, + { + name: "bg_office", + w: 240, + h: 160, + description: + `modern tech office at night, side view: a long desk with two glowing monitors, one desk lamp on, big windows behind showing a city skyline with tiny lights, empty chairs, blue-dark ambience with one warm pool of light, ${PIXEL_STYLE}`, + negative: "people, text, letters", + view: "side", + shading: "medium shading", + detail: "medium detail", + seed: 42004, + }, + { + name: "bg_kitchen", + w: 240, + h: 160, + description: + `small cozy apartment kitchen in the evening, side view: a wooden table with two chairs in the center, kettle on the stove, fridge with sticky notes, warm yellow light from a ceiling lamp, window with dark blue night outside, ${PIXEL_STYLE}`, + negative: "people, text, letters", + view: "side", + shading: "medium shading", + detail: "medium detail", + seed: 42005, + }, + { + name: "bg_stage", + w: 240, + h: 160, + description: + `dark conference stage, side view: a wide glowing presentation screen high on the back wall, a small podium on the right, two spotlight beams, front rows of audience heads as dark silhouettes at the bottom, teal and dark blue palette, ${PIXEL_STYLE}`, + negative: "text, letters, faces", + view: "side", + shading: "medium shading", + detail: "medium detail", + seed: 42006, + }, + { + name: "bg_deskstorm", + w: 240, + h: 160, + description: + `home office at night during a thunderstorm, side view: a desk with a bright monitor in a dark room, a large window behind it streaked with rain, storm clouds and a flash of lightning outside, papers on the floor, a small toy on the shelf, cold blue palette with one bright screen, ${PIXEL_STYLE}`, + negative: "people, text, letters", + view: "side", + shading: "medium shading", + detail: "medium detail", + seed: 42007, + }, + { + name: "bg_harbor", + w: 384, + h: 160, + description: + `calm sea at dawn panorama, side view: open water with gentle waves in the foreground, a distant harbor on the right horizon with orange cranes and warehouses catching the sunrise, soft pink and gold sky low over the waterline, ${PIXEL_STYLE}`, + negative: "people, text, letters, boats", + view: "side", + shading: "medium shading", + detail: "medium detail", + seed: 42008, + }, + { + name: "bg_hill", + w: 240, + h: 160, + description: + `grassy hill at sunset, side view: the hill crest as a dark green silhouette across the lower third, one small tree on the left, a tiny city skyline far below on the right edge, huge warm gradient sky taking most of the frame, a few first stars, ${PIXEL_STYLE}`, + negative: "people, text, letters, sun disc", + view: "side", + shading: "basic shading", + detail: "medium detail", + seed: 42009, + }, + // --- far / parallax strips (transparent background) --------------------------- + { + name: "far_skyline", + w: 240, + h: 80, + description: + `distant city skyline silhouette at dusk, flat dark navy building shapes with a few tiny lit windows, skyline occupies the bottom half, transparent background, ${PIXEL_STYLE}`, + negative: "text", + noBackground: true, + shading: "flat shading", + detail: "low detail", + seed: 42010, + }, + { + name: "far_waves", + w: 240, + h: 48, + description: + `stylized dark teal sea wave strip, repeating rolling wave crests with lighter foam tips, seen from the side, transparent background, ${PIXEL_STYLE}`, + negative: "text", + noBackground: true, + shading: "flat shading", + detail: "low detail", + seed: 42011, + }, + // --- sprites (transparent, side view) --------------------------------------------- + { + name: "spr_kid", + w: 32, + h: 32, + description: + `one small boy in a 1990s red jacket and blue pants, black hair, standing side profile facing right, full body, single character, transparent background, ${PIXEL_STYLE}`, + negative: "multiple characters, text", + noBackground: true, + view: "side", + direction: "east", + outline: "single color black outline", + shading: "basic shading", + detail: "low detail", + seed: 42020, + }, + { + name: "spr_evan", + w: 32, + h: 32, + description: + `one young man with short black hair and glasses, dark green hoodie, jeans, standing side profile facing right, full body, single character, transparent background, ${PIXEL_STYLE}`, + negative: "multiple characters, text", + noBackground: true, + view: "side", + direction: "east", + outline: "single color black outline", + shading: "basic shading", + detail: "low detail", + seed: 42021, + }, + { + name: "spr_dad", + w: 32, + h: 32, + description: + `one middle-aged man in a plain 1990s shirt and trousers, short black hair, standing side profile facing left, full body, single character, transparent background, ${PIXEL_STYLE}`, + negative: "multiple characters, text", + noBackground: true, + view: "side", + direction: "west", + outline: "single color black outline", + shading: "basic shading", + detail: "low detail", + seed: 42022, + }, + { + name: "spr_wife", + w: 32, + h: 32, + description: + `full body tiny sprite of one young woman standing, head to toe visible, shoulder-length black hair, warm beige cardigan and long skirt, side profile facing left, feet at the bottom, single small character, transparent background, ${PIXEL_STYLE}`, + negative: "multiple characters, text", + noBackground: true, + view: "side", + direction: "west", + outline: "single color black outline", + shading: "basic shading", + detail: "low detail", + seed: 52023, + }, + { + name: "spr_child", + w: 32, + h: 32, + description: + `one toddler in yellow overalls, tiny, standing side profile facing right, full body, single character, transparent background, ${PIXEL_STYLE}`, + negative: "multiple characters, text", + noBackground: true, + view: "side", + direction: "east", + outline: "single color black outline", + shading: "basic shading", + detail: "low detail", + seed: 42024, + }, + { + name: "spr_fish", + w: 32, + h: 32, + description: + `one tropical fish swimming right, orange and white, simple cute shape, transparent background, ${PIXEL_STYLE}`, + negative: "multiple fish, text", + noBackground: true, + view: "side", + direction: "east", + outline: "single color black outline", + shading: "basic shading", + detail: "low detail", + seed: 42025, + }, + { + name: "spr_letter", + w: 32, + h: 32, + description: `one white paper envelope with a red and blue airmail border, slightly tilted, transparent background, ${PIXEL_STYLE}`, + negative: "text, letters", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + detail: "low detail", + seed: 42026, + }, + { + name: "spr_bolt", + w: 32, + h: 32, + description: `one stylized lightning bolt gem, indigo purple outer glow with a golden amber core, transparent background, ${PIXEL_STYLE}`, + negative: "text", + noBackground: true, + outline: "single color outline", + shading: "basic shading", + detail: "medium detail", + seed: 42028, + }, + { + name: "spr_ship", + w: 64, + h: 32, + description: `one small dark sailing ship with a bright green triangular sail, side profile facing right, floating, transparent background, ${PIXEL_STYLE}`, + negative: "text, water", + noBackground: true, + view: "side", + direction: "east", + outline: "single color black outline", + shading: "basic shading", + detail: "low detail", + seed: 42029, + }, + { + name: "spr_flag", + w: 32, + h: 32, + description: `one rectangular dark navy fabric flag waving to the right from a tall vertical wooden flagpole on the left edge, cloth rippling in the wind, small white circle emblem on the cloth, transparent background, ${PIXEL_STYLE}`, + negative: "text, skull, arrow, sign", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + detail: "low detail", + seed: 52030, + }, + { + name: "spr_book", + w: 32, + h: 32, + description: `one thick closed textbook with a grey-green cover, side view, transparent background, ${PIXEL_STYLE}`, + negative: "text, letters", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + detail: "low detail", + seed: 42031, + }, + { + name: "spr_orb", + w: 32, + h: 32, + description: `one small glowing round orb, soft cyan core with white highlight, transparent background, ${PIXEL_STYLE}`, + negative: "text", + noBackground: true, + outline: "lineless", + shading: "basic shading", + detail: "low detail", + seed: 42032, + }, + { + name: "spr_star", + w: 32, + h: 32, + description: `one small four-pointed sparkle star, bright green-white, transparent background, ${PIXEL_STYLE}`, + negative: "text", + noBackground: true, + outline: "lineless", + shading: "flat shading", + detail: "low detail", + seed: 42033, + }, + { + name: "spr_drawing", + w: 32, + h: 32, + description: `a child's simple drawing of a house and a sun rendered in bright pixels on a dark blue computer screen rectangle, transparent background, ${PIXEL_STYLE}`, + negative: "text, letters", + noBackground: true, + outline: "lineless", + shading: "flat shading", + detail: "low detail", + seed: 42034, + }, +]; + +if (import.meta.main) { + const force = process.argv.includes("--force"); + const onlyArg = process.argv.indexOf("--only"); + const only = onlyArg >= 0 ? new Set(process.argv[onlyArg + 1].split(",")) : null; + + console.log("pixellab balance:", await balance()); + const manifest: Record = {}; + const manifestPath = OUT + "manifest.json"; + try { + Object.assign(manifest, JSON.parse(await Bun.file(manifestPath).text())); + } catch {} + + for (const spec of SPECS) { + const { name, w, h, ...opts } = spec; + if (only && !only.has(name)) continue; + const out = OUT + name + ".png"; + if (!force && (await Bun.file(out).exists())) { + console.log(` skip ${name} (cached)`); + continue; + } + process.stdout.write(` gen ${name} ${w}x${h}... `); + const png = await pixflux({ ...opts, width: w, height: h }); + await Bun.write(out, png); + manifest[name] = { prompt: spec.description, seed: spec.seed, w, h }; + await Bun.write(manifestPath, JSON.stringify(manifest, null, 2)); + console.log(`ok (${png.length}B)`); + } + console.log("done. balance:", await balance()); +} diff --git a/cine/play.sh b/cine/play.sh new file mode 100644 index 0000000..8796cdb --- /dev/null +++ b/cine/play.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# cine/play.sh — build 《渐进人生》 and open it in mGBA. +# bash cine/play.sh [film.ts] +set -euo pipefail +cd "$(dirname "$0")" +FILM="${1:-film/progressive-life.ts}" +OUT="dist/$(basename "${FILM%.*}").gba" +bun compiler/cli.ts build "$FILM" --out "$OUT" --title PROGLIFE +MGBA_APP="$(brew --prefix mgba 2>/dev/null)/mGBA.app" +if [ -d "$MGBA_APP" ]; then + open -n "$MGBA_APP" --args "$PWD/$OUT" +else + mgba "$OUT" +fi diff --git a/cine/runtime/caption.c b/cine/runtime/caption.c new file mode 100644 index 0000000..5ad9127 --- /dev/null +++ b/cine/runtime/caption.c @@ -0,0 +1,317 @@ +/* cine/runtime/caption.c — typewriter captions, dialog and choice UI on BG0. + * + * Text tokens (compiler-paginated, <=2 lines x 26 cells): + * 0x00 end · 0x0a newline · 0x20..0x7e ASCII halfcell · 0x80|hi,lo fullwidth + * Glyphs are 8x16 halfcells: two stacked 4bpp tiles (64 bytes) DMA'd into a + * ring of C_GLYPH_SLOTS slots inside shared charblock 2, one halfcell per + * frame (typewriter) or synchronously (choice menus, speaker chips). + * Halfcells are baked ink-on-box-color, so text always sits on the navy bar. */ +#include "cine.h" + +#define UI_MAP ((u16 *)SCREENBLOCK(C_SBB_UI)) + +/* per-style geometry */ +typedef struct { + u8 box_r0, box_rows; /* cleared/boxed region */ + u8 text_r0; /* first text row (lines at +0 and +2) */ + u8 margin; /* left col for text (0xff = center per line) */ + u8 boxed; /* draw T_BOX under the region */ +} CapGeo; + +static const CapGeo GEO[4] = { + /* CHIP */ {1, 2, 1, 1, 1}, + /* SUB */ {14, 6, 15, 2, 1}, + /* CARD */ {8, 4, 8, 0xff, 0}, + /* DIALOG*/ {12, 8, 15, 2, 1}, +}; + +typedef struct { + const u8 *tok; + u8 style; + u8 line; /* 0/1 */ + u8 col; + u8 pending; /* second halfcell of a fullwidth glyph (hc+1), 0 = none */ + u8 starts[C_CAP_LINES]; + u8 emitted; +} Typing; + +static Typing ty; +static u8 choice_active_n; +static u8 choice_r0; + +static const u8 *text_tokens(u16 id) { + return film.text_blob + film.text_offs[id]; +} + +/* measure token stream: cell widths per line */ +static u8 measure(const u8 *t, u8 *w0, u8 *w1) { + u8 w[C_CAP_LINES] = {0, 0}; + u8 line = 0; + for (;;) { + u8 c = *t++; + if (c == C_TOK_END) break; + if (c == C_TOK_NL) { + if (++line >= C_CAP_LINES) break; + continue; + } + if (c & 0x80) { + t++; /* low byte */ + w[line] = (u8)(w[line] + 2); + } else { + w[line]++; + } + } + *w0 = w[0]; + *w1 = w[1]; + return (u8)(line + 1); +} + +static void wipe_rows(u8 r0, u8 rows) { + int r, c; + for (r = r0; r < r0 + rows; r++) + for (c = 0; c < 32; c++) UI_MAP[r * 32 + c] = 0; +} + +static void box_rows(u8 r0, u8 rows) { + int r, c; + for (r = r0; r < r0 + rows; r++) + for (c = 0; c < 30; c++) UI_MAP[r * 32 + c] = SE(C_T_BOX, C_PALBANK_UI); +} + +static void accent_row(u8 r, u8 c0, u8 c1) { + int c; + for (c = c0; c < c1; c++) UI_MAP[r * 32 + c] = SE(C_T_BOX_ACCENT, C_PALBANK_UI); +} + +/* Slot plan: the chip caption (place/date) persists across a whole scene while + * subs/dialogs churn, so it gets a private slot range; everything else shares + * a ring above it. */ +#define CHIP_SLOTS 24 +static u16 chip_next; + +static u8 cur_region; /* 0 = general ring, 1 = chip range */ + +static void emit_halfcell(u16 hc, u8 col, u8 row) { + u16 slot; + u16 tile; + if (cur_region) { + slot = chip_next; + chip_next = (u16)((slot + 1) % CHIP_SLOTS); + } else { + slot = g.slot_next; + g.slot_next = (u16)(slot + 1); + if (g.slot_next >= C_GLYPH_SLOTS) g.slot_next = CHIP_SLOTS; + } + tile = (u16)(C_GLYPH_SLOT_BASE + slot * 2); + dma3_copy32(CHARBLOCK(C_CBB_SHARED) + tile * 16, film.glyphs + (u32)hc * 64, 64 / 4); + UI_MAP[row * 32 + col] = SE(tile, C_PALBANK_UI); + UI_MAP[(row + 1) * 32 + col] = (u16)SE(tile + 1, C_PALBANK_UI); +} + +/* fullwidth halfcell ids exceed a u8; the pending right half lives in a u16 */ +static u16 ty_pend16; + +static void type_start(u8 style, u16 text_id) { + const CapGeo *ge = &GEO[style]; + const u8 *t = text_tokens(text_id); + u8 w0, w1; + measure(t, &w0, &w1); + ty.tok = t; + ty.style = style; + ty.line = 0; + ty.emitted = 0; + ty_pend16 = 0; + ty.pending = 0; + if (ge->margin == 0xff) { + ty.starts[0] = (u8)((30 - w0) / 2); + ty.starts[1] = (u8)((30 - (w1 ? w1 : w0)) / 2); + } else { + ty.starts[0] = ge->margin; + ty.starts[1] = ge->margin; + } + ty.col = ty.starts[0]; + g.caption_busy = 1; +} + +/* one halfcell per frame, honoring a pending fullwidth right half */ +void caption_update(void) { + const CapGeo *ge; + u8 row; + if (!g.caption_busy) return; + cur_region = (ty.style == C_CAP_CHIP); + ge = &GEO[ty.style]; + row = (u8)(ge->text_r0 + ty.line * 2); + if (ty_pend16) { + emit_halfcell(ty_pend16, ty.col++, row); + ty_pend16 = 0; + return; + } + for (;;) { + u8 c; + if (!ty.tok) { + g.caption_busy = 0; + return; + } + c = *ty.tok++; + if (c == C_TOK_END) { + ty.tok = 0; + g.caption_busy = 0; + return; + } + if (c == C_TOK_NL) { + ty.line++; + if (ty.line >= C_CAP_LINES) { + ty.tok = 0; + g.caption_busy = 0; + return; + } + ty.col = ty.starts[ty.line]; + row = (u8)(ge->text_r0 + ty.line * 2); + continue; + } + if (c & 0x80) { + u16 gid = (u16)(((c & 0x7f) << 8) | *ty.tok++); + u16 hc = (u16)(C_ASCII_HALF + gid * 2); + emit_halfcell(hc, ty.col++, row); + ty_pend16 = (u16)(hc + 1); + } else { + emit_halfcell((u16)(c - 0x20), ty.col++, row); + } + if ((++ty.emitted & 3) == 1) sfx_play(C_SFX_BLIP); + return; + } +} + +u8 caption_typing(void) { + return g.caption_busy; +} + +/* draw a whole token stream synchronously at (col0, row) */ +static void render_now(const u8 *t, u8 col0, u8 row) { + u8 col = col0; + cur_region = 0; + for (;;) { + u8 c = *t++; + if (c == C_TOK_END) break; + if (c == C_TOK_NL) { + row += 2; + col = col0; + continue; + } + if (c & 0x80) { + u16 gid = (u16)(((c & 0x7f) << 8) | *t++); + u16 hc = (u16)(C_ASCII_HALF + gid * 2); + emit_halfcell(hc, col++, row); + emit_halfcell((u16)(hc + 1), col++, row); + } else { + emit_halfcell((u16)(c - 0x20), col++, row); + } + } +} + +void caption_show(u8 style, u16 text_id) { + const CapGeo *ge = &GEO[style]; + /* finish any in-flight typing instantly */ + while (g.caption_busy) caption_update(); + wipe_rows(ge->box_r0, ge->box_rows); + if (ge->boxed) { + if (style == C_CAP_CHIP) { + u8 w0, w1; + measure(text_tokens(text_id), &w0, &w1); + { + int r, c; + for (r = ge->box_r0; r < ge->box_r0 + 2; r++) + for (c = 0; c < 2 + w0; c++) UI_MAP[r * 32 + c] = SE(C_T_BOX, C_PALBANK_UI); + accent_row((u8)(ge->box_r0 + 2), 0, (u8)(2 + w0)); + } + } else { + box_rows(ge->box_r0, ge->box_rows); + } + } + type_start(style, text_id); + g.cur_text = (u16)(text_id + 1); +} + +void caption_clear(u8 style) { + if (style == 0xff) { + wipe_rows(0, 20); + } else { + const CapGeo *ge = &GEO[style]; + wipe_rows(ge->box_r0, ge->box_rows); + if (style == C_CAP_CHIP) wipe_rows((u8)(ge->box_r0 + 2), 1); + } + if (g.caption_busy && (style == 0xff || style == ty.style)) { + ty.tok = 0; + ty_pend16 = 0; + g.caption_busy = 0; + } +} + +void caption_dialog(u16 speaker, u16 body) { + const CapGeo *ge = &GEO[C_CAP_DIALOG]; + while (g.caption_busy) caption_update(); + wipe_rows(ge->box_r0, ge->box_rows); + box_rows(ge->box_r0, ge->box_rows); + render_now(text_tokens(speaker), 2, (u8)(ge->box_r0 + 0)); /* rows 12-13 */ + accent_row((u8)(ge->box_r0 + 2), 1, 29); /* row 14 */ + type_start(C_CAP_DIALOG, body); /* rows 15.. */ + g.cur_text = (u16)(body + 1); +} + +/* --- choice menu --------------------------------------------------------------- */ +static void choice_cursor_draw(u8 on) { + u8 r = (u8)(choice_r0 + g.choice_cursor * 2); + UI_MAP[r * 32 + 2] = on ? SE(C_T_CURSOR, C_PALBANK_UI) : SE(C_T_BOX, C_PALBANK_UI); +} + +void choice_show(u8 n, const u16 *ids) { + int i; + while (g.caption_busy) caption_update(); + g.choice_n = n; + g.choice_cursor = 0; + choice_r0 = (u8)(18 - 2 * n); + choice_active_n = n; + wipe_rows((u8)(choice_r0 - 1), (u8)(2 * n + 3)); + box_rows((u8)(choice_r0 - 1), (u8)(2 * n + 3)); + for (i = 0; i < n; i++) render_now(text_tokens(ids[i]), 4, (u8)(choice_r0 + i * 2)); + choice_cursor_draw(1); +} + +void choice_update(void) { + if (!choice_active_n) return; + if (key_pressed(KEY_UP) && g.choice_cursor > 0) { + choice_cursor_draw(0); + g.choice_cursor--; + choice_cursor_draw(1); + sfx_play(C_SFX_BLIP); + } + if (key_pressed(KEY_DOWN) && g.choice_cursor + 1 < g.choice_n) { + choice_cursor_draw(0); + g.choice_cursor++; + choice_cursor_draw(1); + sfx_play(C_SFX_BLIP); + } +} + +u8 choice_done(s8 *out) { + if (!choice_active_n) return 0; + if (key_pressed(KEY_A)) { + *out = (s8)g.choice_cursor; + wipe_rows((u8)(choice_r0 - 1), (u8)(2 * choice_active_n + 3)); + choice_active_n = 0; + sfx_play(C_SFX_CONFIRM); + return 1; + } + return 0; +} + +void caption_boot(void) { + wipe_rows(0, 20); + ty.tok = 0; + ty_pend16 = 0; + g.caption_busy = 0; + g.slot_next = CHIP_SLOTS; + chip_next = 0; + cur_region = 0; + choice_active_n = 0; +} diff --git a/cine/runtime/cine.h b/cine/runtime/cine.h new file mode 100644 index 0000000..97b138e --- /dev/null +++ b/cine/runtime/cine.h @@ -0,0 +1,199 @@ +/* cine/runtime/cine.h — internal contract of the @pocketjs/cine GBA runtime. + * + * The compiler residualizes a film into gen_data.c: one CineFilm with per-scene + * palettes, tile sets, tilemaps, OBJ sheets, cue bytecode, gradient tables and + * a global text bank + glyph store. The runtime is fixed; it never parses + * containers — everything is typed arrays in ROM. + * + * Frame order (main.c): input -> vm_tick -> tween_step -> sprites/captions + * update -> fx_apply (registers+shadow) -> debug -> vblank (ISR: OAM DMA, + * scroll regs) -> caption_pump (VRAM glyph streaming). + */ +#ifndef CINE_H +#define CINE_H +#include "gba.h" +#include "cine_gen.h" + +/* --- residual data (gen_data.c) ---------------------------------------------- */ +typedef struct { + u16 tile_base; /* in 4bpp-tile units within the scene OBJ sheet */ + u8 w, h; /* pixels: 8,16,32,64 */ + u8 frames; + u8 palbank; + u8 fps; /* default anim speed, frames per cell */ +} CineProto; + +typedef struct { + const u16 *pal_bg; /* 256 */ + const u16 *pal_obj; /* 256 */ + const u8 *tiles_main; + u16 n_main; /* 4bpp tiles -> charblock 0.. */ + const u8 *tiles_shared; + u16 n_shared; /* -> charblock 2 @ C_FARSKY_BASE */ + const u16 *map_main; /* 32x32, or 64x32 when wide */ + const u16 *map_far; /* 32x32 or 0 */ + const u16 *map_sky; /* 32x32 or 0 */ + u8 wide; + s16 far_fac_q8, sky_fac_q8; /* parallax factors vs camera */ + s16 far_vx_q8, sky_vx_q8; /* autoscroll */ + const u16 *gradient; /* [160] backdrop per scanline, or 0 */ + const u8 *obj_tiles; + u16 obj_bytes; + const CineProto *protos; + u8 n_protos; + const u8 *cue; + u16 cue_len; + s16 cam0, cam_min, cam_max; + u8 raster_mode, raster_amp; + u8 letterbox0; + u16 backdrop; /* PAL[0] when no gradient */ +} CineScene; + +typedef struct { + const CineScene *scenes; + u8 n_scenes; + const u32 *text_offs; /* [n_texts] into text_blob */ + const u8 *text_blob; + u16 n_texts; + const u8 *glyphs; /* halfcells: 64 bytes each (two stacked 4bpp tiles) */ + u16 n_halfcells; + const u8 *ui_bg_tiles; /* 4 tiles: blank, box, accent, cursor */ + const u8 *ui_obj_tiles; /* A-prompt 16x16 + digits 0-9 8x16 */ + u16 ui_obj_bytes; +} CineFilm; + +extern const CineFilm film; + +/* --- runtime state ------------------------------------------------------------ */ +typedef struct { + u8 active, proto, flags, mode; /* mode: 0 static, 1 loop */ + s16 x, y; /* world px (or screen px when SPR_SCREEN) */ + u8 frame, timer, fps; + u8 affine; /* uses matrix 0, double-size */ +} Spr; + +typedef struct { + u8 active, target, ease; + s16 from, to; + u16 t, T; +} Tween; + +typedef struct { + const CineScene *sc; + u8 scene; + u8 pending_scene; /* 0xff none */ + u16 frame; + u8 film_done; + u8 raster_mode; /* current RASTER_* (scene default, RASTER op overrides) */ + + /* cue vm */ + u16 ip; + s16 stack[8]; + u8 sp; + u8 waiting; /* WAITING_* */ + u16 wait_frames; + u8 fade_mode; /* FADE op in flight */ + u8 ctl_slot; + s16 ctl_exit; + u8 ctl_speed_q4; + u8 mash_var; + u16 mash_target; + s8 last_choice; + + /* choice ui */ + u8 choice_n, choice_cursor; + u16 choice_ids[C_MAX_CHOICES]; + + /* world */ + s16 fx[16]; /* tween-target values, TW_* indexed */ + s32 far_off_q8, sky_off_q8; + Spr spr[C_MAX_SPRITES]; + u16 tween_mask; + Tween tw[C_MAX_TWEENS]; + + /* counter hud */ + u8 counter_show, counter_var; + s16 counter_x, counter_y; + s16 counter_prev; + u8 counter_bounce; + + /* text */ + u16 slot_next; + u16 cur_text; + u8 caption_busy; + u8 prompt_on; /* A-prompt blink master switch */ + + s16 vars[C_N_VARS]; + u16 flags; + + u16 keys, keys_prev; + u16 rng; +} Cine; + +extern Cine g; +extern ObjAttr oam_shadow[128]; + +/* values the ISR consumes (computed in fx_apply) */ +extern volatile u32 vbl_count; +extern u16 isr_hofs[4], isr_vofs[4]; /* final per-BG scroll incl. shake */ +extern u16 isr_lb; /* letterbox px */ +extern const u16 *isr_grad; /* 0 = none */ +extern u16 isr_backdrop; +extern u16 isr_wave_amp; /* px, 0 = off */ +extern u8 isr_wave_bg; /* which BG the wave applies to */ +extern u16 isr_wave_phase; + +/* input.c */ +void input_poll(void); +u16 key_held(u16 m); +u16 key_pressed(u16 m); + +/* irq.c */ +void irq_init(void); +void frame_wait(void); + +/* video.c */ +void video_boot(void); +void scene_load(u8 id); + +/* tween.c */ +void tween_start(u8 target, s16 to, u16 T, u8 ease); +void tween_step(void); +s16 *tween_slot_value(u8 target); /* where a target's value lives */ + +/* fx.c */ +void fx_reset(void); +void fx_apply(void); + +/* obj.c */ +void sprites_update(void); +void sprites_draw(void); /* fills oam_shadow */ +s16 spr_screen_x(const Spr *s); + +/* caption.c */ +void caption_boot(void); +void caption_show(u8 style, u16 text_id); +void caption_clear(u8 style); +void caption_dialog(u16 speaker, u16 body); +void caption_update(void); /* typewriter progress, 1 halfcell/frame */ +u8 caption_typing(void); +void choice_show(u8 n, const u16 *ids); +void choice_update(void); +u8 choice_done(s8 *out); + +/* cue_vm.c */ +void vm_start(void); +void vm_tick(void); + +/* sfx.c */ +void sfx_boot(void); +void sfx_play(u8 id); + +/* debug.c */ +void debug_flush(void); + +#define FLAG_GET(i) ((g.flags >> (i)) & 1) +#define FLAG_SET(i) (g.flags |= (u16)(1 << (i))) +#define FLAG_CLR(i) (g.flags &= (u16)~(1 << (i))) + +#endif diff --git a/cine/runtime/cine_gen.h b/cine/runtime/cine_gen.h new file mode 100644 index 0000000..036e5d3 --- /dev/null +++ b/cine/runtime/cine_gen.h @@ -0,0 +1,152 @@ +/* cine_gen.h — GENERATED from cine/spec/cine.ts. Do not edit. */ +#ifndef CINE_GEN_H +#define CINE_GEN_H +#define C_ASCII_HALF 95 +#define C_CAP_CARD 2 +#define C_CAP_CHIP 0 +#define C_CAP_COLS 26 +#define C_CAP_DIALOG 3 +#define C_CAP_LINES 2 +#define C_CAP_SUB 1 +#define C_CBB_MAIN 0 +#define C_CBB_SHARED 2 +#define C_CELLS_H 20 +#define C_CELLS_W 30 +#define C_CMP_EQ 0 +#define C_CMP_GE 5 +#define C_CMP_GT 3 +#define C_CMP_LE 4 +#define C_CMP_LT 2 +#define C_CMP_NE 1 +#define C_COUNTER_DIGITS 6 +#define C_DBG_MAGIC 1162758467 +#define C_DEBUG_ADDR 33554432 +#define C_EASE_IN 1 +#define C_EASE_INOUT 3 +#define C_EASE_LINEAR 0 +#define C_EASE_OUT 2 +#define C_FADE_IN_BLACK 0 +#define C_FADE_IN_WHITE 2 +#define C_FADE_OUT_BLACK 1 +#define C_FADE_OUT_WHITE 3 +#define C_FARSKY_BASE 4 +#define C_FARSKY_MAX 316 +#define C_GLYPH_SLOTS 96 +#define C_GLYPH_SLOT_BASE 320 +#define C_MAIN_TILE_MAX 1024 +#define C_MAX_CHOICES 5 +#define C_MAX_PROTOS 12 +#define C_MAX_SCENES 20 +#define C_MAX_SPRITES 16 +#define C_MAX_TWEENS 16 +#define C_N_FLAGS 16 +#define C_N_VARS 16 +#define C_OAM_COUNTER 16 +#define C_OAM_PROMPT 24 +#define C_OBJ_TILE_MAX 1024 +#define C_PALBANK_FAR 2 +#define C_PALBANK_MAIN 3 +#define C_PALBANK_OBJ_UI 15 +#define C_PALBANK_SKY 1 +#define C_PALBANK_UI 15 +#define C_RASTER_GRADIENT 1 +#define C_RASTER_OFF 0 +#define C_RASTER_WAVE_FAR 3 +#define C_RASTER_WAVE_MAIN 2 +#define C_SBB_FAR 26 +#define C_SBB_MAIN 24 +#define C_SBB_SKY 27 +#define C_SBB_UI 28 +#define C_SCREEN_H 160 +#define C_SCREEN_W 240 +#define C_SFX_BLIP 0 +#define C_SFX_CONFIRM 1 +#define C_SFX_STAR 3 +#define C_SFX_WHOOSH 2 +#define C_SPR_BEHIND 4 +#define C_SPR_GHOST 8 +#define C_SPR_HFLIP 1 +#define C_SPR_SCREEN 2 +#define C_TOK_END 0 +#define C_TOK_NL 10 +#define C_TW_SPRITE_BASE 64 +#define C_T_BLANK 0 +#define C_T_BOX 1 +#define C_T_BOX_ACCENT 2 +#define C_T_CURSOR 3 +#define C_UI_ACCENT 3 +#define C_UI_BOX 2 +#define C_UI_INK 1 +#define C_UI_SHADOW 4 +#define OP_END 0 +#define OP_WAIT 1 +#define OP_WAITA 2 +#define OP_WAIT_TWEENS 3 +#define OP_FADE 4 +#define OP_CAPTION 5 +#define OP_CAPTION_CLR 6 +#define OP_DIALOG 7 +#define OP_CHOICE 8 +#define OP_TWEEN 9 +#define OP_SPRITE_SHOW 10 +#define OP_SPRITE_HIDE 11 +#define OP_SPRITE_ANIM 12 +#define OP_SPRITE_MOVE 13 +#define OP_CONTROL 14 +#define OP_MASH 15 +#define OP_GOTO_SCENE 16 +#define OP_RASTER 17 +#define OP_SFX 18 +#define OP_COUNTER 19 +#define OP_AFFINE 20 +#define OP_LETTERBOX 21 +#define OP_PUSH 32 +#define OP_SET_VAR 33 +#define OP_GET_VAR 34 +#define OP_ADD_VAR 35 +#define OP_SET_FLAG 36 +#define OP_CLR_FLAG 37 +#define OP_GET_FLAG 38 +#define OP_CMP 39 +#define OP_JZ 40 +#define OP_JMP 41 +#define OP_RND 42 +#define OP_POP 43 +#define TW_CAM_X 0 +#define TW_CAM_Y 1 +#define TW_BLDY 2 +#define TW_EVA 3 +#define TW_EVB 4 +#define TW_MOSAIC 5 +#define TW_WAVE_AMP 6 +#define TW_LETTERBOX 7 +#define TW_SHAKE 8 +#define TW_FAR_VX 9 +#define TW_SKY_VX 10 +#define TW_OBJ_SCALE 11 +#define TW_OBJ_ANGLE 12 +#define WAITING_RUN 0 +#define WAITING_A 1 +#define WAITING_DIALOG 2 +#define WAITING_CHOICE 3 +#define WAITING_CONTROL 4 +#define WAITING_MASH 5 +#define WAITING_FILM_DONE 6 +#define WAITING_BUSY 7 +#define DBG_MAGIC_VAL 1162758467 +#define DBGO_MAGIC 0 +#define DBGO_BOOTED 4 +#define DBGO_SCENE 5 +#define DBGO_WAITING 6 +#define DBGO_LAST_CHOICE 7 +#define DBGO_FRAME 8 +#define DBGO_CUE_IP 10 +#define DBGO_CAM_X 12 +#define DBGO_CUR_TEXT 14 +#define DBGO_TWEEN_MASK 16 +#define DBGO_CAPTION_BUSY 18 +#define DBGO_FILM_DONE 19 +#define DBGO_VARS 20 +#define DBGO_SPR0_X 52 +#define DBGO_SPR0_Y 54 +#endif diff --git a/cine/runtime/crt0.s b/cine/runtime/crt0.s new file mode 100644 index 0000000..4a1b329 --- /dev/null +++ b/cine/runtime/crt0.s @@ -0,0 +1,53 @@ +@ cine/runtime/crt0.s — GBA cartridge header + startup for @pocketjs/cine. +@ Logo (0x04) and complement (0xBD) are patched by cine/compiler/rom.ts. + .section .init, "ax" + .global _start + .cpu arm7tdmi + .align 2 + .arm +_start: + b .Lstart + .space 156, 0 @ Nintendo logo (patched) + .ascii "CINE " @ 0xA0: title (12) + .ascii "PJCE" @ 0xAC: game code + .ascii "PJ" @ 0xB0: maker code + .byte 0x96 + .byte 0x00 + .byte 0x00 + .space 7, 0 + .byte 0x00 + .byte 0x00 @ complement (patched) + .space 2, 0 +.Lstart: + @ IRQ-mode stack + msr cpsr_c, #0xD2 + ldr sp, =0x03007FA0 + @ system-mode stack, IRQs ENABLED at the CPU (the cine runtime is + @ interrupt-driven: VBlank frame sync + HBlank raster FX) + msr cpsr_c, #0x5F + ldr sp, =0x03007F00 + + @ copy .data + .iwram code (ROM LMA -> IWRAM VMA) + ldr r0, =__data_lma + ldr r1, =__data_start + ldr r2, =__data_end +.Lcopy: + cmp r1, r2 + ldrlo r3, [r0], #4 + strlo r3, [r1], #4 + blo .Lcopy + + @ zero .bss + ldr r1, =__bss_start + ldr r2, =__bss_end + mov r3, #0 +.Lbss: + cmp r1, r2 + strlo r3, [r1], #4 + blo .Lbss + + ldr r0, =main + mov lr, pc + bx r0 +.Lhang: + b .Lhang diff --git a/cine/runtime/cue_vm.c b/cine/runtime/cue_vm.c new file mode 100644 index 0000000..e1ae48a --- /dev/null +++ b/cine/runtime/cue_vm.c @@ -0,0 +1,356 @@ +/* cine/runtime/cue_vm.c — the suspendable cue interpreter. Non-blocking ops + * (tweens, sprite ops, raster, sfx) execute in a burst each frame; blocking + * ops (wait/fade/caption/dialog/choice/control/mash) park the VM in a waiting + * state that vm_tick services first. */ +#include "cine.h" + +static u8 rd8(void) { + return g.sc->cue[g.ip++]; +} +static u16 rd16(void) { + u16 v = (u16)(g.sc->cue[g.ip] | (g.sc->cue[g.ip + 1] << 8)); + g.ip += 2; + return v; +} +static s16 rds16(void) { + return (s16)rd16(); +} + +static void push(s16 v) { + if (g.sp < 8) g.stack[g.sp++] = v; +} +static s16 pop(void) { + return g.sp ? g.stack[--g.sp] : 0; +} + +void vm_start(void) { + g.ip = 0; + g.sp = 0; + g.waiting = WAITING_RUN; +} + +/* --- waiting-state service ---------------------------------------------------- */ +static void service_control(void) { + Spr *s = &g.spr[g.ctl_slot]; + static s16 frac; + s16 step = 0; + if (key_held(KEY_LEFT)) step = (s16)-g.ctl_speed_q4; + else if (key_held(KEY_RIGHT)) step = (s16)g.ctl_speed_q4; + if (step) { + frac += step; + s->x += frac / 16; + frac %= 16; + s->flags = step < 0 ? (u8)(s->flags | C_SPR_HFLIP) : (u8)(s->flags & ~C_SPR_HFLIP); + s->mode = 1; /* walk anim */ + } else { + s->mode = 0; + s->frame = 0; + } + /* soft camera follow */ + { + s16 target = (s16)(s->x - 120); + if (target < g.sc->cam_min) target = g.sc->cam_min; + if (target > g.sc->cam_max) target = g.sc->cam_max; + g.fx[TW_CAM_X] += (s16)((target - g.fx[TW_CAM_X]) >> 3); + } + /* clamp actor to the pannable world */ + if (s->x < g.sc->cam_min + 8) s->x = (s16)(g.sc->cam_min + 8); + if (s->x > g.sc->cam_max + 232) s->x = (s16)(g.sc->cam_max + 232); + if (s->x > g.ctl_exit - 3 && s->x < g.ctl_exit + 3) { + s->mode = 0; + s->frame = 0; + g.waiting = WAITING_RUN; + } +} + +static void service(void) { + switch (g.waiting) { + case WAITING_BUSY: + if (g.wait_frames) { + g.wait_frames--; + if (g.wait_frames == 0 && !g.caption_busy) g.waiting = WAITING_RUN; + } else if (!g.caption_busy) { + g.waiting = WAITING_RUN; + } + break; + case WAITING_A: + g.prompt_on = 1; + if (key_pressed(KEY_A)) { + g.prompt_on = 0; + sfx_play(C_SFX_CONFIRM); + g.waiting = WAITING_RUN; + } + break; + case WAITING_DIALOG: + if (g.caption_busy) { + if (key_pressed(KEY_A)) { /* fast-forward typing */ + while (g.caption_busy) caption_update(); + } + break; + } + g.prompt_on = 1; + if (key_pressed(KEY_A)) { + g.prompt_on = 0; + caption_clear(C_CAP_DIALOG); + sfx_play(C_SFX_CONFIRM); + g.waiting = WAITING_RUN; + } + break; + case WAITING_CHOICE: { + s8 out; + choice_update(); + if (choice_done(&out)) { + g.last_choice = out; + push(out); + g.waiting = WAITING_RUN; + } + break; + } + case WAITING_CONTROL: + service_control(); + break; + case WAITING_MASH: + if (key_pressed(KEY_A)) { + g.vars[g.mash_var]++; + sfx_play(C_SFX_STAR); + } + if (g.vars[g.mash_var] >= (s16)g.mash_target) g.waiting = WAITING_RUN; + break; + default: + break; + } +} + +/* wait for all tweens */ +static u8 tweens_running(void) { + return g.tween_mask != 0; +} + +void vm_tick(void) { + int budget = 64; + + if (g.waiting == WAITING_FILM_DONE) return; + if (g.waiting != WAITING_RUN) { + service(); + if (g.waiting != WAITING_RUN) return; + } + + while (budget-- > 0) { + u8 op = rd8(); + switch (op) { + case OP_END: + if (g.scene + 1 < film.n_scenes) { + g.pending_scene = (u8)(g.scene + 1); + } else { + g.film_done = 1; + g.waiting = WAITING_FILM_DONE; + } + return; + case OP_WAIT: + g.wait_frames = rd16(); + g.waiting = WAITING_BUSY; + return; + case OP_WAITA: + g.waiting = WAITING_A; + return; + case OP_WAIT_TWEENS: + if (tweens_running()) { + g.ip--; /* re-run this op next frame */ + return; + } + break; + case OP_FADE: { + u8 mode = rd8(); + u16 T = rd16(); + g.fade_mode = mode; + tween_start(TW_BLDY, (mode == C_FADE_IN_BLACK || mode == C_FADE_IN_WHITE) ? 0 : 16, T, + C_EASE_LINEAR); + g.wait_frames = T; + g.waiting = WAITING_BUSY; + return; + } + case OP_CAPTION: { + u8 style = rd8(); + u16 id = rd16(); + caption_show(style, id); + g.waiting = WAITING_BUSY; + g.wait_frames = 0; + return; + } + case OP_CAPTION_CLR: + caption_clear(rd8()); + break; + case OP_DIALOG: { + u16 sp = rd16(); + u16 body = rd16(); + caption_dialog(sp, body); + g.waiting = WAITING_DIALOG; + return; + } + case OP_CHOICE: { + u8 n = rd8(); + int i; + for (i = 0; i < n; i++) g.choice_ids[i] = rd16(); + choice_show(n, g.choice_ids); + g.waiting = WAITING_CHOICE; + return; + } + case OP_TWEEN: { + u8 target = rd8(); + u8 ease = rd8(); + s16 to = rds16(); + u16 T = rd16(); + tween_start(target, to, T, ease); + break; + } + case OP_SPRITE_SHOW: { + u8 slot = rd8(); + u8 proto = rd8(); + s16 x = rds16(); + s16 y = rds16(); + u8 flags = rd8(); + Spr *s = &g.spr[slot]; + s->active = 1; + s->proto = proto; + s->x = x; + s->y = y; + s->flags = flags; + s->mode = 0; + s->frame = 0; + s->timer = 0; + s->fps = g.sc->protos[proto].fps; + s->affine = 0; + break; + } + case OP_SPRITE_HIDE: + g.spr[rd8()].active = 0; + break; + case OP_SPRITE_ANIM: { + u8 slot = rd8(); + u8 mode = rd8(); + u8 arg = rd8(); + Spr *s = &g.spr[slot]; + s->mode = mode; + if (mode == 0) s->frame = arg; + else if (arg) s->fps = arg; + break; + } + case OP_SPRITE_MOVE: { + u8 slot = rd8(); + u8 ease = rd8(); + s16 x = rds16(); + s16 y = rds16(); + u16 T = rd16(); + tween_start((u8)(C_TW_SPRITE_BASE + slot * 2), x, T, ease); + tween_start((u8)(C_TW_SPRITE_BASE + slot * 2 + 1), y, T, ease); + break; + } + case OP_CONTROL: + g.ctl_slot = rd8(); + g.ctl_exit = rds16(); + g.ctl_speed_q4 = rd8(); + g.waiting = WAITING_CONTROL; + return; + case OP_MASH: + g.mash_var = rd8(); + g.mash_target = rd16(); + g.waiting = WAITING_MASH; + return; + case OP_GOTO_SCENE: + g.pending_scene = rd8(); + return; + case OP_RASTER: { + u8 mode = rd8(); + u8 amp = rd8(); + g.raster_mode = mode; + if (mode == C_RASTER_WAVE_MAIN || mode == C_RASTER_WAVE_FAR) g.fx[TW_WAVE_AMP] = amp; + break; + } + case OP_SFX: + sfx_play(rd8()); + break; + case OP_COUNTER: { + u8 var = rd8(); + u8 show = rd8(); + s16 x = rds16(); + s16 y = rds16(); + g.counter_var = var; + g.counter_show = show; + g.counter_x = x; + g.counter_y = y; + g.counter_prev = g.vars[var]; + break; + } + case OP_AFFINE: { + u8 slot = rd8(); + g.spr[slot].affine = rd8(); + break; + } + case OP_LETTERBOX: { + u8 px = rd8(); + u16 T = rd16(); + tween_start(TW_LETTERBOX, px, T, C_EASE_INOUT); + break; + } + case OP_PUSH: + push(rds16()); + break; + case OP_SET_VAR: + g.vars[rd8()] = pop(); + break; + case OP_GET_VAR: + push(g.vars[rd8()]); + break; + case OP_ADD_VAR: { + u8 v = rd8(); + g.vars[v] = (s16)(g.vars[v] + rds16()); + break; + } + case OP_SET_FLAG: + FLAG_SET(rd8()); + break; + case OP_CLR_FLAG: + FLAG_CLR(rd8()); + break; + case OP_GET_FLAG: + push((s16)FLAG_GET(rd8())); + break; + case OP_CMP: { + u8 k = rd8(); + s16 b = pop(); + s16 a = pop(); + s16 r = 0; + switch (k) { + case C_CMP_EQ: r = a == b; break; + case C_CMP_NE: r = a != b; break; + case C_CMP_LT: r = a < b; break; + case C_CMP_GT: r = a > b; break; + case C_CMP_LE: r = a <= b; break; + case C_CMP_GE: r = a >= b; break; + } + push(r); + break; + } + case OP_JZ: { + u16 to = rd16(); + if (pop() == 0) g.ip = to; + break; + } + case OP_JMP: + g.ip = rd16(); + break; + case OP_RND: { + u8 n = rd8(); + push((s16)((g.rng >> 4) % n)); + break; + } + case OP_POP: + pop(); + break; + default: + /* corrupt cue: halt scene */ + g.waiting = WAITING_FILM_DONE; + return; + } + } +} diff --git a/cine/runtime/debug.c b/cine/runtime/debug.c new file mode 100644 index 0000000..54f2d82 --- /dev/null +++ b/cine/runtime/debug.c @@ -0,0 +1,25 @@ +/* cine/runtime/debug.c — fixed EWRAM debug block; the E2E contract. */ +#include "cine.h" + +#define D8(off) (*(vu8 *)(C_DEBUG_ADDR + (off))) +#define D16(off) (*(vu16 *)(C_DEBUG_ADDR + (off))) +#define D32(off) (*(vu32 *)(C_DEBUG_ADDR + (off))) + +void debug_flush(void) { + int i; + D32(DBGO_MAGIC) = DBG_MAGIC_VAL; + D8(DBGO_BOOTED) = 1; + D8(DBGO_SCENE) = g.scene; + D8(DBGO_WAITING) = g.waiting; + D8(DBGO_LAST_CHOICE) = (u8)g.last_choice; + D16(DBGO_FRAME) = g.frame; + D16(DBGO_CUE_IP) = g.ip; + D16(DBGO_CAM_X) = (u16)g.fx[TW_CAM_X]; + D16(DBGO_CUR_TEXT) = g.cur_text; + D16(DBGO_TWEEN_MASK) = g.tween_mask; + D8(DBGO_CAPTION_BUSY) = g.caption_busy; + D8(DBGO_FILM_DONE) = g.film_done; + for (i = 0; i < C_N_VARS; i++) D16(DBGO_VARS + i * 2) = (u16)g.vars[i]; + D16(DBGO_SPR0_X) = (u16)g.spr[0].x; + D16(DBGO_SPR0_Y) = (u16)g.spr[0].y; +} diff --git a/cine/runtime/fx.c b/cine/runtime/fx.c new file mode 100644 index 0000000..92c47c3 --- /dev/null +++ b/cine/runtime/fx.c @@ -0,0 +1,124 @@ +/* cine/runtime/fx.c — maps tween-target values onto GBA effect hardware: + * camera/parallax scroll, BLDCNT fades (black/white) and BG1 ghost alpha, + * mosaic, WIN0 letterbox (raster-assisted for the bars), screen shake, and + * OBJ affine matrix 0 (scale/angle). Runs once per frame after tween_step. */ +#include "cine.h" + +extern s8 sin8[256]; + +void fx_reset(void) { + int i; + for (i = 0; i < 16; i++) g.fx[i] = 0; + g.fx[TW_EVA] = 16; + g.fx[TW_EVB] = 0; + g.fx[TW_OBJ_SCALE] = 256; + g.far_off_q8 = 0; + g.sky_off_q8 = 0; + for (i = 0; i < C_MAX_TWEENS; i++) g.tw[i].active = 0; + g.tween_mask = 0; +} + +static s16 clamp16(s32 v, s32 lo, s32 hi) { + if (v < lo) return (s16)lo; + if (v > hi) return (s16)hi; + return (s16)v; +} + +void fx_apply(void) { + const CineScene *sc = g.sc; + s16 cam = g.fx[TW_CAM_X]; + s16 camy = g.fx[TW_CAM_Y]; + s16 shake = g.fx[TW_SHAKE]; + s16 sx = 0, sy = 0; + u16 dispcnt; + + if (shake > 0) { + sx = (s16)((g.rng >> 4) % (u16)(2 * shake + 1)) - shake; + sy = (s16)((g.rng >> 9) % (u16)(shake + 1)) - (shake >> 1); + } + + /* autoscroll accumulators */ + g.far_off_q8 += sc->far_vx_q8 + g.fx[TW_FAR_VX]; + g.sky_off_q8 += sc->sky_vx_q8 + g.fx[TW_SKY_VX]; + + isr_hofs[0] = 0; + isr_vofs[0] = 0; + isr_hofs[1] = (u16)(cam + sx); + isr_vofs[1] = (u16)(camy + sy); + isr_hofs[2] = (u16)((((s32)cam * sc->far_fac_q8) >> 8) + (s16)(g.far_off_q8 >> 8) + sx); + isr_vofs[2] = (u16)(((s32)camy * sc->far_fac_q8) >> 8); + isr_hofs[3] = (u16)((((s32)cam * sc->sky_fac_q8) >> 8) + (s16)(g.sky_off_q8 >> 8)); + isr_vofs[3] = 0; + + /* --- blending state machine ------------------------------------------------ */ + { + s16 y = g.fx[TW_BLDY]; + if (y < 0) y = 0; + if (y > 16) y = 16; + if (y > 0) { + REG_BLDCNT = (u16)(((g.fade_mode >= C_FADE_IN_WHITE) ? BLD_MODE_WHITE : BLD_MODE_BLACK) | BLD_ALL); + REG_BLDY = (u16)y; + } else if (g.fx[TW_EVA] != 16 || g.fx[TW_EVB] != 0) { + /* ghost/crossfade: BG1(+OBJ) over far/sky/backdrop */ + REG_BLDCNT = (u16)(BLD_MODE_ALPHA | BLD_BG1 | BLD_OBJ | BLD_2ND(BLD_BG2 | BLD_BG3 | BLD_BD)); + REG_BLDALPHA = (u16)((g.fx[TW_EVA] & 31) | ((g.fx[TW_EVB] & 31) << 8)); + REG_BLDY = 0; + } else { + /* idle: keep 2nd targets armed so SPR_GHOST OBJs still blend */ + REG_BLDCNT = (u16)(BLD_MODE_OFF | BLD_2ND(BLD_BG1 | BLD_BG2 | BLD_BG3 | BLD_BD)); + REG_BLDALPHA = (u16)(10 | (8 << 8)); + REG_BLDY = 0; + } + } + + /* --- mosaic ------------------------------------------------------------------ */ + { + u16 m = (u16)(g.fx[TW_MOSAIC] & 15); + REG_MOSAIC = (u16)(m | (m << 4) | (m << 8) | (m << 12)); + } + + /* --- letterbox (WIN0 band + ISR blackout outside) ----------------------------- */ + { + s16 lb = clamp16(g.fx[TW_LETTERBOX], 0, 48); + isr_lb = (u16)lb; + dispcnt = DCNT_MODE0 | DCNT_OBJ | DCNT_OBJ_1D | DCNT_BG0 | DCNT_BG1; + if (sc->map_far) dispcnt |= DCNT_BG2; + if (sc->map_sky) dispcnt |= DCNT_BG3; + if (lb > 0) { + REG_WIN0H = 240; + REG_WIN0V = (u16)((lb << 8) | (160 - lb)); + REG_WININ = WIN_ALL; + REG_WINOUT = WIN_BLD; /* backdrop only (ISR paints it black) */ + dispcnt |= DCNT_WIN0; + } + REG_DISPCNT = dispcnt; + } + + /* --- raster feed ---------------------------------------------------------------- */ + isr_backdrop = sc->backdrop; + isr_grad = (g.raster_mode == C_RASTER_GRADIENT && sc->gradient) ? sc->gradient : 0; + if (g.raster_mode == C_RASTER_WAVE_MAIN || g.raster_mode == C_RASTER_WAVE_FAR) { + isr_wave_bg = (g.raster_mode == C_RASTER_WAVE_MAIN) ? 1 : 2; + isr_wave_amp = (u16)(g.fx[TW_WAVE_AMP] < 0 ? 0 : g.fx[TW_WAVE_AMP]); + } else { + isr_wave_amp = 0; + } + + /* --- OBJ affine matrix 0 ----------------------------------------------------------- */ + { + s16 scale = g.fx[TW_OBJ_SCALE]; + u8 ang = (u8)g.fx[TW_OBJ_ANGLE]; + s32 c = sin8[(u8)(ang + 64)]; /* q7 cos */ + s32 s = sin8[ang]; + s32 inv; /* q8 of 1/scale */ + ObjAffine *m = &OAM_AFF[0]; + ObjAffine *ms = (ObjAffine *)oam_shadow; + if (scale < 16) scale = 16; + inv = 65536 / scale; + ms->pa = (s16)((c * inv) >> 7); + ms->pb = (s16)((-s * inv) >> 7); + ms->pc = (s16)((s * inv) >> 7); + ms->pd = (s16)((c * inv) >> 7); + (void)m; + } +} diff --git a/cine/runtime/gba.h b/cine/runtime/gba.h new file mode 100644 index 0000000..6bfb4e4 --- /dev/null +++ b/cine/runtime/gba.h @@ -0,0 +1,177 @@ +/* cine/runtime/gba.h — GBA hardware definitions for the cine runtime. + * Fuller register set than aot's: blending, mosaic, windows, affine OBJ, + * HBlank/VBlank IRQs, PSG sound. Freestanding, per GBATEK/Tonc. */ +#ifndef CINE_GBA_H +#define CINE_GBA_H +#include + +typedef uint8_t u8; +typedef uint16_t u16; +typedef uint32_t u32; +typedef int8_t s8; +typedef int16_t s16; +typedef int32_t s32; +typedef volatile uint8_t vu8; +typedef volatile uint16_t vu16; +typedef volatile uint32_t vu32; + +#define MEM_EWRAM 0x02000000 +#define MEM_IWRAM 0x03000000 +#define MEM_IO 0x04000000 +#define MEM_PAL 0x05000000 +#define MEM_VRAM 0x06000000 +#define MEM_OAM 0x07000000 + +/* --- display ---------------------------------------------------------------- */ +#define REG_DISPCNT (*(vu16 *)(MEM_IO + 0x0000)) +#define REG_DISPSTAT (*(vu16 *)(MEM_IO + 0x0004)) +#define REG_VCOUNT (*(vu16 *)(MEM_IO + 0x0006)) +#define REG_BGCNT(n) (*(vu16 *)(MEM_IO + 0x0008 + (n) * 2)) +#define REG_BGHOFS(n) (*(vu16 *)(MEM_IO + 0x0010 + (n) * 4)) +#define REG_BGVOFS(n) (*(vu16 *)(MEM_IO + 0x0012 + (n) * 4)) + +#define DCNT_MODE0 0x0000 +#define DCNT_FORCE_BLANK 0x0080 +#define DCNT_BG0 0x0100 +#define DCNT_BG1 0x0200 +#define DCNT_BG2 0x0400 +#define DCNT_BG3 0x0800 +#define DCNT_OBJ 0x1000 +#define DCNT_WIN0 0x2000 +#define DCNT_OBJ_1D 0x0040 + +#define DSTAT_VBL_IRQ 0x0008 +#define DSTAT_HBL_IRQ 0x0010 + +#define BG_4BPP 0x0000 +#define BG_MOSAIC 0x0040 +#define BG_PRIO(n) ((n) & 3) +#define BG_CBB(n) (((n) & 3) << 2) +#define BG_SBB(n) (((n) & 0x1f) << 8) +#define BG_SIZE_32x32 0x0000 +#define BG_SIZE_64x32 0x4000 + +/* --- effects ------------------------------------------------------------------ */ +#define REG_MOSAIC (*(vu16 *)(MEM_IO + 0x004c)) +#define REG_BLDCNT (*(vu16 *)(MEM_IO + 0x0050)) +#define REG_BLDALPHA (*(vu16 *)(MEM_IO + 0x0052)) +#define REG_BLDY (*(vu16 *)(MEM_IO + 0x0054)) + +#define BLD_BG0 0x0001 +#define BLD_BG1 0x0002 +#define BLD_BG2 0x0004 +#define BLD_BG3 0x0008 +#define BLD_OBJ 0x0010 +#define BLD_BD 0x0020 +#define BLD_ALL 0x003f +#define BLD_MODE_OFF 0x0000 +#define BLD_MODE_ALPHA 0x0040 +#define BLD_MODE_WHITE 0x0080 +#define BLD_MODE_BLACK 0x00c0 +#define BLD_2ND(t) ((t) << 8) + +/* --- windows -------------------------------------------------------------------- */ +#define REG_WIN0H (*(vu16 *)(MEM_IO + 0x0040)) +#define REG_WIN0V (*(vu16 *)(MEM_IO + 0x0044)) +#define REG_WININ (*(vu16 *)(MEM_IO + 0x0048)) +#define REG_WINOUT (*(vu16 *)(MEM_IO + 0x004a)) +#define WIN_BG0 0x01 +#define WIN_BG1 0x02 +#define WIN_BG2 0x04 +#define WIN_BG3 0x08 +#define WIN_OBJ 0x10 +#define WIN_BLD 0x20 +#define WIN_ALL 0x3f + +/* --- interrupts ----------------------------------------------------------------- */ +#define REG_IE (*(vu16 *)(MEM_IO + 0x0200)) +#define REG_IF (*(vu16 *)(MEM_IO + 0x0202)) +#define REG_IME (*(vu16 *)(MEM_IO + 0x0208)) +#define IRQ_VBLANK 0x0001 +#define IRQ_HBLANK 0x0002 +#define ISR_VECTOR (*(vu32 *)(0x03007ffc)) +#define BIOS_IF (*(vu16 *)(0x03007ff8)) + +/* --- input ------------------------------------------------------------------------ */ +#define REG_KEYINPUT (*(vu16 *)(MEM_IO + 0x0130)) +#define KEY_A 0x0001 +#define KEY_B 0x0002 +#define KEY_SELECT 0x0004 +#define KEY_START 0x0008 +#define KEY_RIGHT 0x0010 +#define KEY_LEFT 0x0020 +#define KEY_UP 0x0040 +#define KEY_DOWN 0x0080 +#define KEY_MASK 0x03ff + +/* --- PSG sound -------------------------------------------------------------------- */ +#define REG_SOUNDCNT_L (*(vu16 *)(MEM_IO + 0x0080)) +#define REG_SOUNDCNT_H (*(vu16 *)(MEM_IO + 0x0082)) +#define REG_SOUNDCNT_X (*(vu16 *)(MEM_IO + 0x0084)) +#define REG_SOUND1CNT_L (*(vu16 *)(MEM_IO + 0x0060)) /* sweep */ +#define REG_SOUND1CNT_H (*(vu16 *)(MEM_IO + 0x0062)) /* duty/len/env */ +#define REG_SOUND1CNT_X (*(vu16 *)(MEM_IO + 0x0064)) /* freq/ctrl */ +#define REG_SOUND4CNT_L (*(vu16 *)(MEM_IO + 0x0078)) /* noise len/env */ +#define REG_SOUND4CNT_H (*(vu16 *)(MEM_IO + 0x007c)) /* noise freq/ctrl */ + +/* --- VRAM / palettes ----------------------------------------------------------------- */ +#define CHARBLOCK(n) ((u16 *)(MEM_VRAM + (n) * 0x4000)) +#define SCREENBLOCK(n) ((u16 *)(MEM_VRAM + (n) * 0x800)) +#define OBJ_VRAM ((u16 *)(MEM_VRAM + 0x10000)) +#define BG_PAL ((vu16 *)MEM_PAL) +#define OBJ_PAL ((vu16 *)(MEM_PAL + 0x200)) +#define SE(tile, palbank) (((tile) & 0x3ff) | (((palbank) & 0xf) << 12)) +#define SE_HFLIP 0x0400 +#define SE_VFLIP 0x0800 + +/* --- OAM ------------------------------------------------------------------------------- */ +typedef struct { + u16 attr0, attr1, attr2, fill; +} ObjAttr; +typedef struct { + u16 f0[3]; + s16 pa; + u16 f1[3]; + s16 pb; + u16 f2[3]; + s16 pc; + u16 f3[3]; + s16 pd; +} ObjAffine; +#define OAM_MEM ((ObjAttr *)MEM_OAM) +#define OAM_AFF ((ObjAffine *)MEM_OAM) + +#define ATTR0_Y(y) ((y) & 0xff) +#define ATTR0_AFFINE 0x0100 +#define ATTR0_HIDE 0x0200 +#define ATTR0_AFF_DBL 0x0300 +#define ATTR0_BLEND 0x0400 +#define ATTR0_MOSAIC 0x1000 +#define ATTR0_SQUARE 0x0000 +#define ATTR0_WIDE 0x4000 +#define ATTR0_TALL 0x8000 +#define ATTR1_X(x) ((x) & 0x1ff) +#define ATTR1_AFF(n) (((n) & 31) << 9) +#define ATTR1_HFLIP 0x1000 +#define ATTR1_VFLIP 0x2000 +#define ATTR1_SIZE(n) (((n) & 3) << 14) +#define ATTR2_TILE(t) ((t) & 0x3ff) +#define ATTR2_PRIO(p) (((p) & 3) << 10) +#define ATTR2_PALBANK(n) (((n) & 0xf) << 12) + +/* --- DMA3 -------------------------------------------------------------------------------- */ +#define REG_DMA3SAD (*(vu32 *)(MEM_IO + 0x00d4)) +#define REG_DMA3DAD (*(vu32 *)(MEM_IO + 0x00d8)) +#define REG_DMA3CNT (*(vu32 *)(MEM_IO + 0x00dc)) +#define DMA_ENABLE 0x80000000 +#define DMA_32 0x04000000 + +static inline void dma3_copy32(volatile void *dst, const void *src, u32 words) { + REG_DMA3SAD = (u32)src; + REG_DMA3DAD = (u32)dst; + REG_DMA3CNT = words | DMA_ENABLE | DMA_32; +} + +#define IWRAM_CODE __attribute__((section(".iwram.text"), long_call, target("arm"))) + +#endif diff --git a/cine/runtime/gba.ld b/cine/runtime/gba.ld new file mode 100644 index 0000000..3ac7d01 --- /dev/null +++ b/cine/runtime/gba.ld @@ -0,0 +1,44 @@ +/* cine/runtime/gba.ld — GBA link map for @pocketjs/cine. + .iwram.text (the HBlank ISR) is copied to IWRAM together with .data so the + per-scanline raster handler never pays ROM waitstates. EWRAM stays free for + the debug block at 0x02000000. */ +OUTPUT_FORMAT("elf32-littlearm") +OUTPUT_ARCH(arm) +ENTRY(_start) + +MEMORY { + rom (rx) : ORIGIN = 0x08000000, LENGTH = 32M + iwram (rwx) : ORIGIN = 0x03000000, LENGTH = 32K + ewram (rwx) : ORIGIN = 0x02000000, LENGTH = 256K +} + +SECTIONS { + .init : { + KEEP(*(.init)) + *(.text .text.*) + *(.rodata .rodata.*) + . = ALIGN(4); + } > rom + + __data_lma = .; + .data : AT (__data_lma) { + . = ALIGN(4); + __data_start = .; + *(.iwram .iwram.*) + . = ALIGN(4); + *(.data .data.*) + . = ALIGN(4); + __data_end = .; + } > iwram + + .bss (NOLOAD) : { + . = ALIGN(4); + __bss_start = .; + *(.bss .bss.*) + *(COMMON) + . = ALIGN(4); + __bss_end = .; + } > iwram + + /DISCARD/ : { *(.comment) *(.note*) *(.ARM.attributes) } +} diff --git a/cine/runtime/input.c b/cine/runtime/input.c new file mode 100644 index 0000000..0885edd --- /dev/null +++ b/cine/runtime/input.c @@ -0,0 +1,16 @@ +/* cine/runtime/input.c */ +#include "cine.h" + +void input_poll(void) { + g.keys_prev = g.keys; + g.keys = (u16)(~REG_KEYINPUT) & KEY_MASK; + g.rng = g.rng * 25173 + 13849; +} + +u16 key_held(u16 m) { + return g.keys & m; +} + +u16 key_pressed(u16 m) { + return g.keys & ~g.keys_prev & m; +} diff --git a/cine/runtime/irq.c b/cine/runtime/irq.c new file mode 100644 index 0000000..50c8985 --- /dev/null +++ b/cine/runtime/irq.c @@ -0,0 +1,79 @@ +/* cine/runtime/irq.c — VBlank/HBlank interrupt service. + * + * The HBlank handler is the raster engine: per scanline it writes the backdrop + * color (sky gradients get ~160 shades from a 15-color-per-bank system; the + * letterbox forces the out-of-window band to black) and, when a wave effect is + * active, a sine offset into one BG's HOFS. It runs from IWRAM (.iwram.text, + * copied by crt0) so it fits comfortably inside the 272-cycle HBlank. + * + * The VBlank handler DMAs the OAM shadow, latches the frame's scroll values, + * preloads line 0's backdrop, and bumps the frame counter. */ +#include "cine.h" + +volatile u32 vbl_count; +u16 isr_hofs[4], isr_vofs[4]; +u16 isr_lb; +const u16 *isr_grad; +u16 isr_backdrop; +u16 isr_wave_amp; +u8 isr_wave_bg; +u16 isr_wave_phase; + +s8 sin8[256]; /* q7 sine, built at boot (quarter-wave parabola — FX grade) */ + +ObjAttr oam_shadow[128]; + +IWRAM_CODE static void master_isr(void) { + u16 f = REG_IF; + if (f & IRQ_HBLANK) { + u32 vc = REG_VCOUNT; + u32 line = (vc >= 227) ? 0 : vc + 1; /* the line about to be drawn */ + if (line < 160) { + u16 c; + if (isr_lb && (line < isr_lb || line >= 160u - isr_lb)) c = 0; + else if (isr_grad) c = isr_grad[line]; + else c = isr_backdrop; + BG_PAL[0] = c; + if (isr_wave_amp) { + u32 idx = (line * 3 + isr_wave_phase) & 255; + s32 s = sin8[idx]; + REG_BGHOFS(isr_wave_bg) = (u16)(isr_hofs[isr_wave_bg] + ((s * (s32)isr_wave_amp) >> 7)); + } + } + } + if (f & IRQ_VBLANK) { + int i; + dma3_copy32(OAM_MEM, oam_shadow, sizeof(oam_shadow) / 4); + for (i = 0; i < 4; i++) { + REG_BGHOFS(i) = isr_hofs[i]; + REG_BGVOFS(i) = isr_vofs[i]; + } + BG_PAL[0] = isr_lb ? 0 : (isr_grad ? isr_grad[0] : isr_backdrop); + isr_wave_phase += 2; + vbl_count++; + } + REG_IF = f; + BIOS_IF |= f; +} + +void irq_init(void) { + int i; + for (i = 0; i < 128; i++) { + /* parabola per quadrant: peaks ~127 at i=64 */ + s32 v = (i * (128 - i)) >> 5; + if (v > 127) v = 127; + sin8[i] = (s8)v; + sin8[128 + i] = (s8)-v; + } + REG_IME = 0; + ISR_VECTOR = (u32)master_isr; + REG_DISPSTAT = DSTAT_VBL_IRQ | DSTAT_HBL_IRQ; + REG_IE = IRQ_VBLANK | IRQ_HBLANK; + REG_IF = 0xffff; + REG_IME = 1; +} + +void frame_wait(void) { + u32 f = vbl_count; + while (vbl_count == f) {} +} diff --git a/cine/runtime/main.c b/cine/runtime/main.c new file mode 100644 index 0000000..3f7eb8b --- /dev/null +++ b/cine/runtime/main.c @@ -0,0 +1,25 @@ +/* cine/runtime/main.c — fixed frame loop. */ +#include "cine.h" + +int main(void) { + video_boot(); + sfx_boot(); + irq_init(); + scene_load(0); + debug_flush(); + + for (;;) { + input_poll(); + if (g.pending_scene != 0xff) scene_load(g.pending_scene); + vm_tick(); + tween_step(); + sprites_update(); + caption_update(); + fx_apply(); + sprites_draw(); + debug_flush(); + g.frame++; + frame_wait(); + } + return 0; +} diff --git a/cine/runtime/obj.c b/cine/runtime/obj.c new file mode 100644 index 0000000..e64ebce --- /dev/null +++ b/cine/runtime/obj.c @@ -0,0 +1,123 @@ +/* cine/runtime/obj.c — scene sprites, the var-bound digit counter HUD, and the + * blinking A prompt. Everything draws into oam_shadow; the VBlank ISR DMAs it. */ +#include "cine.h" + +#define OBJ_UI_BASE 1000 +#define UI_TILE_PROMPT (OBJ_UI_BASE) +#define UI_TILE_DIGIT(d) (OBJ_UI_BASE + 4 + (d) * 2) + +/* attr0 shape / attr1 size for w x h */ +static u16 shape_bits(u8 w, u8 h) { + if (w == h) return ATTR0_SQUARE; + return (w > h) ? ATTR0_WIDE : ATTR0_TALL; +} +static u16 size_bits(u8 w, u8 h) { + u8 m = (w > h) ? w : h; + u8 n = (w > h) ? h : w; + if (w == h) return (u16)(w == 8 ? 0 : w == 16 ? 1 : w == 32 ? 2 : 3); + if (m == 16) return 0; /* 16x8 */ + if (m == 32 && n == 8) return 1; /* 32x8 */ + if (m == 32) return 2; /* 32x16 */ + return 3; /* 64x32 */ +} + +void sprites_update(void) { + int i; + for (i = 0; i < C_MAX_SPRITES; i++) { + Spr *s = &g.spr[i]; + const CineProto *p; + if (!s->active || s->mode != 1) continue; + p = &g.sc->protos[s->proto]; + if (p->frames <= 1) continue; + if (++s->timer >= s->fps) { + s->timer = 0; + s->frame++; + if (s->frame >= p->frames) s->frame = 0; + } + } + if (g.counter_bounce) g.counter_bounce--; +} + +s16 spr_screen_x(const Spr *s) { + return (s->flags & C_SPR_SCREEN) ? s->x : (s16)(s->x - g.fx[TW_CAM_X]); +} + +void sprites_draw(void) { + int i; + u16 mos = (g.fx[TW_MOSAIC] > 0) ? ATTR0_MOSAIC : 0; + + for (i = 0; i < C_MAX_SPRITES; i++) { + Spr *s = &g.spr[i]; + ObjAttr *o = &oam_shadow[i]; + const CineProto *p; + s16 sx, sy; + if (!s->active) { + o->attr0 = ATTR0_HIDE; + continue; + } + p = &g.sc->protos[s->proto]; + sx = spr_screen_x(s); + sy = (s->flags & C_SPR_SCREEN) ? s->y : (s16)(s->y - g.fx[TW_CAM_Y]); + if (sx <= -(s16)p->w * 2 || sx >= 240 + p->w || sy <= -(s16)p->h * 2 || sy >= 160 + p->h) { + o->attr0 = ATTR0_HIDE; + continue; + } + if (s->affine) { + /* matrix 0, double-size render area, centered on (x,y) */ + o->attr0 = (u16)(ATTR0_Y(sy - p->h) | ATTR0_AFF_DBL | shape_bits(p->w, p->h) | mos | + ((s->flags & C_SPR_GHOST) ? ATTR0_BLEND : 0)); + o->attr1 = (u16)(ATTR1_X(sx - p->w) | ATTR1_AFF(0) | (size_bits(p->w, p->h) << 14)); + } else { + o->attr0 = (u16)(ATTR0_Y(sy) | shape_bits(p->w, p->h) | mos | + ((s->flags & C_SPR_GHOST) ? ATTR0_BLEND : 0)); + o->attr1 = (u16)(ATTR1_X(sx) | ((s->flags & C_SPR_HFLIP) ? ATTR1_HFLIP : 0) | + (size_bits(p->w, p->h) << 14)); + } + o->attr2 = (u16)(ATTR2_TILE(p->tile_base + s->frame * ((p->w / 8) * (p->h / 8))) | + ATTR2_PRIO((s->flags & C_SPR_BEHIND) ? 3 : 1) | ATTR2_PALBANK(p->palbank)); + } + + /* counter HUD: right-aligned digits bound to a var */ + { + s16 v = g.counter_show ? g.vars[g.counter_var] : -1; + int d; + if (g.counter_show && v != g.counter_prev) { + g.counter_bounce = 8; + g.counter_prev = v; + } + for (d = 0; d < C_COUNTER_DIGITS; d++) { + ObjAttr *o = &oam_shadow[C_OAM_COUNTER + d]; + if (!g.counter_show || v < 0) { + o->attr0 = ATTR0_HIDE; + continue; + } + { + s16 dx = (s16)(g.counter_x - d * 9); + s16 dy = (s16)(g.counter_y - ((g.counter_bounce > 4) ? (g.counter_bounce - 4) : 0)); + u16 digit = (u16)(v % 10); + o->attr0 = (u16)(ATTR0_Y(dy) | ATTR0_TALL); + o->attr1 = (u16)(ATTR1_X(dx) | (0 << 14)); /* 8x16 */ + o->attr2 = (u16)(ATTR2_TILE(UI_TILE_DIGIT(digit)) | ATTR2_PRIO(0) | ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + v /= 10; + if (v == 0 && d + 1 < C_COUNTER_DIGITS) { + int k; + for (k = d + 1; k < C_COUNTER_DIGITS; k++) oam_shadow[C_OAM_COUNTER + k].attr0 = ATTR0_HIDE; + break; + } + } + } + } + + /* blinking A prompt */ + { + ObjAttr *o = &oam_shadow[C_OAM_PROMPT]; + u8 blink = (g.frame & 31) < 22; + if (g.prompt_on && blink) { + o->attr0 = (u16)(ATTR0_Y(142) | ATTR0_SQUARE); + o->attr1 = (u16)(ATTR1_X(220) | (1 << 14)); /* 16x16 */ + o->attr2 = (u16)(ATTR2_TILE(UI_TILE_PROMPT) | ATTR2_PRIO(0) | ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + } else { + o->attr0 = ATTR0_HIDE; + } + } +} diff --git a/cine/runtime/sfx.c b/cine/runtime/sfx.c new file mode 100644 index 0000000..dbcbbad --- /dev/null +++ b/cine/runtime/sfx.c @@ -0,0 +1,33 @@ +/* cine/runtime/sfx.c — PSG micro-sfx: typewriter blip, confirm, whoosh, star. + * Channel 1 (square w/ sweep) + channel 4 (noise). Deliberately tiny. */ +#include "cine.h" + +void sfx_boot(void) { + REG_SOUNDCNT_X = 0x0080; /* master enable */ + REG_SOUNDCNT_L = 0xff77; /* all PSG channels L+R, max PSG volume */ + REG_SOUNDCNT_H = 0x0002; /* PSG full mix */ +} + +void sfx_play(u8 id) { + switch (id) { + case C_SFX_BLIP: /* short mid square tick */ + REG_SOUND1CNT_L = 0x0000; + REG_SOUND1CNT_H = 0x4140 | (3 << 12); /* duty 25%, env dec fast, quiet */ + REG_SOUND1CNT_X = 0x8000 | 1848; /* ~1.3 kHz */ + break; + case C_SFX_CONFIRM: /* brighter, longer */ + REG_SOUND1CNT_L = 0x0000; + REG_SOUND1CNT_H = 0x0180 | (10 << 12); /* duty 50%, slow decay */ + REG_SOUND1CNT_X = 0x8000 | 1985; /* ~2.1 kHz */ + break; + case C_SFX_WHOOSH: /* noise swell */ + REG_SOUND4CNT_L = (u16)(0x0300 | (9 << 12)); + REG_SOUND4CNT_H = 0x8000 | 0x0034; + break; + case C_SFX_STAR: /* rising sweep chirp */ + REG_SOUND1CNT_L = 0x0017; /* sweep up, shift 7 */ + REG_SOUND1CNT_H = 0x0180 | (9 << 12); + REG_SOUND1CNT_X = 0x8000 | 1750; + break; + } +} diff --git a/cine/runtime/tween.c b/cine/runtime/tween.c new file mode 100644 index 0000000..68898de --- /dev/null +++ b/cine/runtime/tween.c @@ -0,0 +1,81 @@ +/* cine/runtime/tween.c — 16-slot property tween engine. + * Targets < 16 index g.fx[] (camera, blend, mosaic, wave, letterbox, shake, + * autoscroll, affine scale/angle). Targets >= C_TW_SPRITE_BASE address sprite + * slot x/y. One active tween per target; restarting replaces. */ +#include "cine.h" + +s16 *tween_slot_value(u8 target) { + if (target >= C_TW_SPRITE_BASE) { + u8 slot = (u8)((target - C_TW_SPRITE_BASE) >> 1); + if (slot >= C_MAX_SPRITES) return 0; + return (target & 1) ? &g.spr[slot].y : &g.spr[slot].x; + } + if (target < 16) return &g.fx[target]; + return 0; +} + +static u16 easef(u16 t, u16 T, u8 mode) { + u32 f = ((u32)t << 8) / T; + switch (mode) { + case C_EASE_IN: + f = (f * f) >> 8; + break; + case C_EASE_OUT: { + u32 inv = 256 - f; + f = 256 - ((inv * inv) >> 8); + break; + } + case C_EASE_INOUT: + f = ((u32)f * f * (768 - 2 * f)) >> 16; + break; + } + return (u16)f; +} + +void tween_start(u8 target, s16 to, u16 T, u8 ease) { + int i, free_i = -1; + s16 *v = tween_slot_value(target); + if (!v) return; + if (T == 0) { + *v = to; + return; + } + for (i = 0; i < C_MAX_TWEENS; i++) { + if (g.tw[i].active && g.tw[i].target == target) { + free_i = i; /* replace in place */ + break; + } + if (!g.tw[i].active && free_i < 0) free_i = i; + } + if (free_i < 0) return; /* out of slots: drop (compiler budget prevents this) */ + g.tw[free_i].active = 1; + g.tw[free_i].target = target; + g.tw[free_i].ease = ease; + g.tw[free_i].from = *v; + g.tw[free_i].to = to; + g.tw[free_i].t = 0; + g.tw[free_i].T = T; +} + +void tween_step(void) { + int i; + u16 mask = 0; + for (i = 0; i < C_MAX_TWEENS; i++) { + Tween *tw = &g.tw[i]; + s16 *v; + if (!tw->active) continue; + v = tween_slot_value(tw->target); + tw->t++; + if (tw->t >= tw->T) { + *v = tw->to; + tw->active = 0; + continue; + } + { + u16 f = easef(tw->t, tw->T, tw->ease); + *v = (s16)(tw->from + (((s32)(tw->to - tw->from) * f) >> 8)); + } + mask |= (u16)(1u << i); + } + g.tween_mask = mask; +} diff --git a/cine/runtime/video.c b/cine/runtime/video.c new file mode 100644 index 0000000..dd09576 --- /dev/null +++ b/cine/runtime/video.c @@ -0,0 +1,99 @@ +/* cine/runtime/video.c — boot video state + whole-scene VRAM loads. + * Scene loads happen behind force-blank (the author fades/mosaics out first), + * so we can DMA palettes, three tile sets, four tilemaps and the OBJ sheets + * in one go without fighting the PPU. */ +#include "cine.h" + +#define OBJ_UI_BASE 1000 /* A-prompt (4 tiles) + digits 0-9 (2 tiles each) */ + +Cine g; + +static void bgcnt_setup(u8 wide) { + REG_BGCNT(0) = BG_4BPP | BG_PRIO(0) | BG_CBB(C_CBB_SHARED) | BG_SBB(C_SBB_UI) | BG_SIZE_32x32; + REG_BGCNT(1) = BG_4BPP | BG_PRIO(2) | BG_CBB(C_CBB_MAIN) | BG_SBB(C_SBB_MAIN) | BG_MOSAIC | + (wide ? BG_SIZE_64x32 : BG_SIZE_32x32); + REG_BGCNT(2) = BG_4BPP | BG_PRIO(3) | BG_CBB(C_CBB_SHARED) | BG_SBB(C_SBB_FAR) | BG_MOSAIC | BG_SIZE_32x32; + REG_BGCNT(3) = BG_4BPP | BG_PRIO(3) | BG_CBB(C_CBB_SHARED) | BG_SBB(C_SBB_SKY) | BG_SIZE_32x32; +} + +void video_boot(void) { + REG_DISPCNT = DCNT_FORCE_BLANK; + /* fixed UI BG tiles at shared-charblock 0..3 (blank/box/accent/cursor) */ + dma3_copy32(CHARBLOCK(C_CBB_SHARED), film.ui_bg_tiles, (4 * 32) / 4); + /* built-in UI OBJ sheet (A prompt + digits) at the top of OBJ VRAM */ + dma3_copy32(OBJ_VRAM + OBJ_UI_BASE * 16, film.ui_obj_tiles, film.ui_obj_bytes / 4); + bgcnt_setup(0); + g.pending_scene = 0xff; + g.last_choice = -1; + g.rng = 0xbeef; +} + +static void fill_map(u16 *sb, u16 se, u32 count) { + u32 i; + u32 v = se | ((u32)se << 16); + for (i = 0; i < count / 2; i++) ((u32 *)sb)[i] = v; +} + +void scene_load(u8 id) { + const CineScene *sc = &film.scenes[id]; + int i; + + REG_DISPCNT = DCNT_FORCE_BLANK; + g.scene = id; + g.sc = sc; + g.pending_scene = 0xff; + g.frame = 0; + + /* palettes (backdrop entry 0 is raster-owned; keep a copy) */ + dma3_copy32(BG_PAL, sc->pal_bg, 512 / 4); + dma3_copy32(OBJ_PAL, sc->pal_obj, 512 / 4); + + /* tiles */ + if (sc->n_main) dma3_copy32(CHARBLOCK(C_CBB_MAIN), sc->tiles_main, ((u32)sc->n_main * 32) / 4); + if (sc->n_shared) + dma3_copy32(CHARBLOCK(C_CBB_SHARED) + C_FARSKY_BASE * 16, sc->tiles_shared, ((u32)sc->n_shared * 32) / 4); + /* clear glyph slots */ + { + u32 *gz = (u32 *)(CHARBLOCK(C_CBB_SHARED) + C_GLYPH_SLOT_BASE * 16); + for (i = 0; i < (C_GLYPH_SLOTS * 2 * 32) / 4; i++) gz[i] = 0; + } + + /* maps */ + dma3_copy32(SCREENBLOCK(C_SBB_MAIN), sc->map_main, (sc->wide ? 0x1000u : 0x800u) / 4); + if (sc->map_far) dma3_copy32(SCREENBLOCK(C_SBB_FAR), sc->map_far, 0x800 / 4); + else fill_map(SCREENBLOCK(C_SBB_FAR), 0, 0x400); + if (sc->map_sky) dma3_copy32(SCREENBLOCK(C_SBB_SKY), sc->map_sky, 0x800 / 4); + else fill_map(SCREENBLOCK(C_SBB_SKY), 0, 0x400); + fill_map(SCREENBLOCK(C_SBB_UI), 0, 0x400); + + /* OBJ sheets (below the persistent UI sheet) */ + if (sc->obj_bytes) dma3_copy32(OBJ_VRAM, sc->obj_tiles, sc->obj_bytes / 4); + + bgcnt_setup(sc->wide); + + /* world + fx reset */ + fx_reset(); + g.fx[TW_CAM_X] = sc->cam0; + g.fx[TW_LETTERBOX] = sc->letterbox0; + g.fx[TW_BLDY] = 16; /* scenes wake up faded out; the cue's FADE reveals */ + g.raster_mode = sc->raster_mode; + if (sc->raster_mode == C_RASTER_WAVE_MAIN || sc->raster_mode == C_RASTER_WAVE_FAR) + g.fx[TW_WAVE_AMP] = sc->raster_amp; + + for (i = 0; i < C_MAX_SPRITES; i++) g.spr[i].active = 0; + for (i = 0; i < 128; i++) oam_shadow[i].attr0 = ATTR0_HIDE; + g.counter_show = 0; + g.counter_bounce = 0; + g.prompt_on = 0; + + caption_boot(); + + /* cue vm */ + g.ip = 0; + g.sp = 0; + g.waiting = WAITING_RUN; + g.wait_frames = 0; + g.cur_text = 0; + + fx_apply(); /* also re-enables the display */ +} diff --git a/cine/spec/cine.ts b/cine/spec/cine.ts new file mode 100644 index 0000000..cad98c7 --- /dev/null +++ b/cine/spec/cine.ts @@ -0,0 +1,243 @@ +// cine/spec/cine.ts — single source of truth for the @pocketjs/cine binary +// contract: cue bytecode, tween targets, VRAM/palette plan, debug block. +// +// @pocketjs/cine is an INDEPENDENT sibling of @pocketjs/aot: a GBA-only +// cinematic-montage DSL. Scenes are declarative (4-layer parallax, Mode 0); +// each scene's `play` generator is residualized into bytecode for a tiny +// suspendable cue VM. The point is to flex GBA 2D: alpha blending, mosaic, +// affine OBJs, HBlank raster FX (per-scanline gradients, wave), windows +// (smooth letterbox), typewriter CJK captions, PSG micro-sfx. +// +// Mirrored into C by spec/gen-c.ts -> runtime/cine_gen.h. + +// --- screen / layer plan ------------------------------------------------------ +export const SCREEN_W = 240; +export const SCREEN_H = 160; +export const CELLS_W = 30; +export const CELLS_H = 20; + +// Mode 0. Fixed semantic layers: +// BG0 = ui (captions/dialog/choice), prio 0, charbase 2, screenblock 28 +// BG1 = main stage, prio 2, charbase 0 (may spill into charblock 1; <=1024 tiles) +// BG2 = far parallax, prio 3, charbase 2, screenblock 26 +// BG3 = sky, prio 3 (drawn under far via BG order), charbase 2, screenblock 27 +export const CBB_MAIN = 0; +export const CBB_SHARED = 2; // ui + far + sky share charblock 2 (512 tiles) +export const SBB_MAIN = 24; // +25 when wide (64x32 map) +export const SBB_FAR = 26; +export const SBB_SKY = 27; +export const SBB_UI = 28; + +// Shared charblock 2 allocation (tile indices relative to charbase 2): +export const T_BLANK = 0; +export const T_BOX = 1; // caption/dialog box fill +export const T_BOX_ACCENT = 2; // vue-green underline tile +export const T_CURSOR = 3; // choice cursor (8x8 arrow) +export const FARSKY_BASE = 4; // far+sky art tiles, up to GLYPH_SLOT area +export const GLYPH_SLOT_BASE = 320; // 96 halfcell slots x 2 stacked tiles = 192 +export const GLYPH_SLOTS = 96; +export const FARSKY_MAX = GLYPH_SLOT_BASE - FARSKY_BASE; // 316 tiles + +export const MAIN_TILE_MAX = 1024; // charblocks 0+1 + +// BG palette banks: +export const PALBANK_SKY = 1; +export const PALBANK_FAR = 2; +export const PALBANK_MAIN = 3; +export const PALBANK_UI = 15; // 0 transparent, 1 ink, 2 box, 3 accent, 4 shadow +export const UI_INK = 1; +export const UI_BOX = 2; +export const UI_ACCENT = 3; +export const UI_SHADOW = 4; + +// OBJ: charblock 4-5 (1024 4bpp tiles), 1D mapping. Last palbank = built-in UI +// sheet (A-prompt 16x16 + digits 0-9 8x16) appended by the compiler. +export const OBJ_TILE_MAX = 1024; +export const PALBANK_OBJ_UI = 15; + +// OAM slot plan: 0..15 scene sprites, 16..23 counter digits, 24 A-prompt. +export const MAX_SPRITES = 16; +export const OAM_COUNTER = 16; +export const COUNTER_DIGITS = 6; +export const OAM_PROMPT = 24; + +export const MAX_SCENES = 20; +export const MAX_PROTOS = 12; +export const MAX_TWEENS = 16; +export const N_VARS = 16; +export const N_FLAGS = 16; +export const MAX_CHOICES = 5; + +// --- cue bytecode ------------------------------------------------------------- +// One byte op + little-endian args. Blocking ops suspend the VM until done. +export const OP = { + END: 0x00, // scene done -> next scene in film order (or film end) + WAIT: 0x01, // u16 frames + WAITA: 0x02, // blinking A prompt + WAIT_TWEENS: 0x03, + FADE: 0x04, // u8 mode (FADE_*), u16 frames — blocking BLDY fade + CAPTION: 0x05, // u8 style, u16 text — blocking while typing, stays shown + CAPTION_CLR: 0x06, // u8 style (0xff = all) + DIALOG: 0x07, // u16 speaker_text, u16 body_text — type, waitA, clear + CHOICE: 0x08, // u8 n, u16 ids[n] — pushes result + TWEEN: 0x09, // u8 target, u8 ease, s16 to, u16 frames — non-blocking + SPRITE_SHOW: 0x0a, // u8 slot, u8 proto, s16 x, s16 y, u8 flags + SPRITE_HIDE: 0x0b, // u8 slot + SPRITE_ANIM: 0x0c, // u8 slot, u8 mode (0 static,1 loop), u8 frame_or_fps + SPRITE_MOVE: 0x0d, // u8 slot, u8 ease, s16 x, s16 y, u16 frames — non-blocking + CONTROL: 0x0e, // u8 slot, s16 exit_x, u8 speed_q4 — blocking L/R walk + MASH: 0x0f, // u8 var, u16 target — blocking, A presses increment var + GOTO_SCENE: 0x10, // u8 scene + RASTER: 0x11, // u8 mode (RASTER_*), u8 amp_or_table + SFX: 0x12, // u8 id + COUNTER: 0x13, // u8 var, u8 show, s16 x, s16 y — OBJ digit HUD bound to var + AFFINE: 0x14, // u8 slot, u8 on — sprite uses affine matrix 0 (dbl-size) + LETTERBOX: 0x15, // u8 px, u16 frames — tween letterbox bar height + + PUSH: 0x20, // s16 + SET_VAR: 0x21, // u8 (pop) + GET_VAR: 0x22, // u8 (push) + ADD_VAR: 0x23, // u8, s16 (var += imm) + SET_FLAG: 0x24, // u8 + CLR_FLAG: 0x25, // u8 + GET_FLAG: 0x26, // u8 (push) + CMP: 0x27, // u8 kind (pop b, a; push a?b) + JZ: 0x28, // u16 (pop; jump if 0) + JMP: 0x29, // u16 + RND: 0x2a, // u8 n (push 0..n-1) + POP: 0x2b, +} as const; + +export const CMP_EQ = 0, CMP_NE = 1, CMP_LT = 2, CMP_GT = 3, CMP_LE = 4, CMP_GE = 5; + +export const FADE_IN_BLACK = 0; +export const FADE_OUT_BLACK = 1; +export const FADE_IN_WHITE = 2; +export const FADE_OUT_WHITE = 3; + +export const RASTER_OFF = 0; +export const RASTER_GRADIENT = 1; // per-line backdrop color from scene table +export const RASTER_WAVE_MAIN = 2; // per-line BG1 HOFS sine (amp tweenable) +export const RASTER_WAVE_FAR = 3; // per-line BG2 HOFS sine + +// caption styles +export const CAP_CHIP = 0; // top-left place/date chip (1 line) +export const CAP_SUB = 1; // bottom subtitle bar (<=2 lines) +export const CAP_CARD = 2; // centered title card (<=2 lines) +export const CAP_DIALOG = 3; // internal: dialog body (speaker chip + 2 lines) + +// sprite flags (SPRITE_SHOW) +export const SPR_HFLIP = 1; +export const SPR_SCREEN = 2; // screen-space (ignores camera) +export const SPR_BEHIND = 4; // prio 3 (behind main stage) +export const SPR_GHOST = 8; // OBJ semi-transparency (memory figures) + +// tween targets +export const TW = { + CAM_X: 0, + CAM_Y: 1, + BLDY: 2, // 0..16 + EVA: 3, // 0..16 (BG1 1st-target alpha) + EVB: 4, + MOSAIC: 5, // 0..15 + WAVE_AMP: 6, // raster sine amplitude, px + LETTERBOX: 7, // bar height px (0..32), windowed, raster-assisted + SHAKE: 8, // screen shake amplitude px + FAR_VX: 9, // far autoscroll, q8 px/frame + SKY_VX: 10, // sky autoscroll, q8 px/frame + OBJ_SCALE: 11, // affine matrix 0 scale, q8 (256 = 1.0) + OBJ_ANGLE: 12, // affine matrix 0 angle, 0..255 +} as const; +// sprite x/y tweens are encoded as 0x40 | slot<<1 | axis +export const TW_SPRITE_BASE = 0x40; + +export const EASE_LINEAR = 0, EASE_IN = 1, EASE_OUT = 2, EASE_INOUT = 3; + +export const SFX_BLIP = 0, SFX_CONFIRM = 1, SFX_WHOOSH = 2, SFX_STAR = 3; + +// waiting states (debug block `waiting`) +export const WAITING = { + RUN: 0, + A: 1, + DIALOG: 2, + CHOICE: 3, + CONTROL: 4, + MASH: 5, + FILM_DONE: 6, + BUSY: 7, // wait/fade/typewriter +} as const; + +// --- text encoding (same scheme as aot cjk16) --------------------------------- +// 0x00 end, 0x0a newline, 0x20..0x7e ASCII (halfcell), 0x80|hi lo fullwidth +// glyph id. Glyph store = 95 baked ASCII halfcells + 2 halfcells per fullwidth +// glyph; each halfcell = two stacked 4bpp 8x8 tiles = 64 bytes, ink=UI_INK on 0. +export const TOK_END = 0x00; +export const TOK_NL = 0x0a; +export const ASCII_HALF = 95; // halfcells 0..94 = codepoints 0x20..0x7e +export const CAP_COLS = 26; // max text cells per caption line +export const CAP_LINES = 2; + +// --- debug block (EWRAM 0x02000000) -------------------------------------------- +export const DEBUG_ADDR = 0x02000000; +export const DBG_MAGIC = 0x454e4943; // "CINE" +export const DBG = { + MAGIC: 0x00, // u32 + BOOTED: 0x04, // u8 + SCENE: 0x05, // u8 + WAITING: 0x06, // u8 (WAITING.*) + LAST_CHOICE: 0x07, // s8 (-1 none) + FRAME: 0x08, // u16 scene-local frame + CUE_IP: 0x0a, // u16 + CAM_X: 0x0c, // s16 + CUR_TEXT: 0x0e, // u16 (last caption/dialog text id + 1; 0 = none) + TWEEN_MASK: 0x10, // u16 + CAPTION_BUSY: 0x12, // u8 + FILM_DONE: 0x13, // u8 + VARS: 0x14, // s16[16] + SPR0_X: 0x34, // s16 (sprite slot 0 world x — control assertions) + SPR0_Y: 0x36, // s16 +} as const; + +// --- ByteWriter ---------------------------------------------------------------- +export class ByteWriter { + private buf: number[] = []; + get length(): number { + return this.buf.length; + } + u8(v: number): this { + this.buf.push(v & 0xff); + return this; + } + u16(v: number): this { + this.buf.push(v & 0xff, (v >> 8) & 0xff); + return this; + } + i16(v: number): this { + return this.u16(v & 0xffff); + } + u32(v: number): this { + this.buf.push(v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff); + return this; + } + bytes(b: ArrayLike): this { + for (let i = 0; i < b.length; i++) this.buf.push(b[i] & 0xff); + return this; + } + patchU16(at: number, v: number): this { + this.buf[at] = v & 0xff; + this.buf[at + 1] = (v >> 8) & 0xff; + return this; + } + toUint8Array(): Uint8Array { + return Uint8Array.from(this.buf); + } +} + +export function rgb555(r: number, g: number, b: number): number { + return ((r >> 3) & 31) | (((g >> 3) & 31) << 5) | (((b >> 3) & 31) << 10); +} + +export function hex555(hex: string): number { + const h = hex.replace("#", ""); + return rgb555(parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)); +} diff --git a/cine/spec/gen-c.ts b/cine/spec/gen-c.ts new file mode 100644 index 0000000..e1235fd --- /dev/null +++ b/cine/spec/gen-c.ts @@ -0,0 +1,26 @@ +// cine/spec/gen-c.ts — mirror spec/cine.ts into runtime/cine_gen.h. +// bun cine/spec/gen-c.ts +import * as S from "./cine.ts"; + +const lines: string[] = [ + "/* cine_gen.h — GENERATED from cine/spec/cine.ts. Do not edit. */", + "#ifndef CINE_GEN_H", + "#define CINE_GEN_H", +]; + +const def = (name: string, v: number): void => { + lines.push(`#define ${name} ${v}`); +}; + +for (const [k, v] of Object.entries(S)) { + if (typeof v === "number") def(`C_${k}`, v); +} +for (const [k, v] of Object.entries(S.OP)) def(`OP_${k}`, v); +for (const [k, v] of Object.entries(S.TW)) def(`TW_${k}`, v); +for (const [k, v] of Object.entries(S.WAITING)) def(`WAITING_${k}`, v); +def("DBG_MAGIC_VAL", S.DBG_MAGIC); +for (const [k, v] of Object.entries(S.DBG)) def(`DBGO_${k}`, v); + +lines.push("#endif", ""); +await Bun.write(new URL("../runtime/cine_gen.h", import.meta.url).pathname, lines.join("\n")); +console.log("wrote runtime/cine_gen.h"); diff --git a/cine/test/crop.ts b/cine/test/crop.ts new file mode 100644 index 0000000..4ae09b5 --- /dev/null +++ b/cine/test/crop.ts @@ -0,0 +1,22 @@ +// cine/test/crop.ts — crop + integer-scale a PNG region for close inspection. +// bun test/crop.ts [scale] +import { decodePng, encodePng } from "../compiler/png.ts"; + +const [path, xs, ys, ws, hs, ss] = process.argv.slice(2); +const img = decodePng(new Uint8Array(await Bun.file(path).arrayBuffer())); +const x0 = +xs, y0 = +ys, w = +ws, h = +hs, s = +(ss ?? 4); +const out = new Uint8Array(w * s * h * s * 4); +for (let y = 0; y < h * s; y++) + for (let x = 0; x < w * s; x++) { + const sx = x0 + Math.floor(x / s); + const sy = y0 + Math.floor(y / s); + const si = (sy * img.width + sx) * 4; + const di = (y * w * s + x) * 4; + out[di] = img.rgba[si]; + out[di + 1] = img.rgba[si + 1]; + out[di + 2] = img.rgba[si + 2]; + out[di + 3] = 255; + } +const dst = path.replace(/\.png$/, `.crop.png`); +await Bun.write(dst, encodePng(out, w * s, h * s)); +console.log(dst); diff --git a/cine/test/e2e.ts b/cine/test/e2e.ts new file mode 100644 index 0000000..b6fe915 --- /dev/null +++ b/cine/test/e2e.ts @@ -0,0 +1,261 @@ +// cine/test/e2e.ts — build 《渐进人生》 and play it headlessly in mGBA, +// asserting the debug-block contract scene by scene: menu navigation, control +// walks, mash counters, choice branches (incl. the hesitate loop), white-fade +// scene chaining, credits, and the coda -> title loop. +// +// bun test/e2e.ts + +import { $ } from "bun"; +import { compileFilm } from "../compiler/index.ts"; +import { emitGenData } from "../compiler/emit.ts"; +import { buildRom } from "../compiler/rom.ts"; +import { DEBUG_ADDR, DBG, DBG_MAGIC, WAITING } from "../spec/cine.ts"; + +const HERE = new URL(".", import.meta.url).pathname; +const ROOT = HERE + "../"; +const RUNNER = ROOT + "../aot/test/harness/mgba_runner"; +const ROM = ROOT + "dist/progressive-life.gba"; +const SHOTS = ROOT + "dist/shots"; + +type Step = + | { op: "advance"; frames: number } + | { op: "press"; buttons: string[]; frames: number; release?: number } + | { op: "read"; name: string; addr: number; size: 1 | 2 | 4 } + | { op: "screenshot"; path: string }; + +const A = (n = 8): Step => ({ op: "press", buttons: ["A"], frames: 1, release: n }); +const DOWN: Step = { op: "press", buttons: ["DOWN"], frames: 1, release: 6 }; +const adv = (frames: number): Step => ({ op: "advance", frames }); +const rd = (name: string, off: number, size: 1 | 2 | 4 = 1): Step => ({ op: "read", name, addr: DEBUG_ADDR + off, size }); +const shot = (n: string): Step => ({ op: "screenshot", path: `${SHOTS}/${n}.ppm` }); + +async function run(steps: Step[]): Promise> { + const scenario = ROOT + "dist/e2e-scenario.json"; + await Bun.write(scenario, JSON.stringify({ steps })); + const out = await $`${RUNNER} ${ROM} ${scenario}`.text(); + const line = out.trim().split("\n").reverse().find((l) => l.trim().startsWith("{")); + if (!line) throw new Error("runner produced no JSON:\n" + out); + const parsed = JSON.parse(line); + if (!parsed.ok) throw new Error("runner error: " + JSON.stringify(parsed)); + return parsed.reads ?? {}; +} + +let passed = 0; +let failed = 0; +function check(name: string, got: unknown, want: unknown): void { + const ok = got === want; + console.log(` ${ok ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}: got ${got}${ok ? "" : `, want ${want}`}`); + ok ? passed++ : failed++; +} +function checkNear(name: string, got: number, want: number, tol: number): void { + const ok = Math.abs(got - want) <= tol; + console.log(` ${ok ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}: got ${got}${ok ? "" : `, want ${want}±${tol}`}`); + ok ? passed++ : failed++; +} + +// menu helpers: boot -> title card -> (从头播放 | chapter n) +const bootToMenu: Step[] = [adv(230)]; +const pickChapter = (page2: boolean, index: number): Step[] => { + const s: Step[] = [...bootToMenu, DOWN, A(), adv(10)]; + const downs = page2 ? 4 : index; + for (let i = 0; i < downs; i++) s.push(DOWN); + s.push(A(), adv(10)); + if (page2) { + for (let i = 0; i < index; i++) s.push(DOWN); + s.push(A(), adv(10)); + } + return s; +}; + +async function main(): Promise { + console.log("Building 渐进人生..."); + const film = await compileFilm(ROOT + "film/progressive-life.ts"); + const rom = await buildRom(emitGenData(film), ROM, "PROGLIFE"); + await $`mkdir -p ${SHOTS}`.quiet(); + console.log(`ROM: ${rom.size} bytes, ${film.scenes.length} scenes, ${film.debug.texts.length} texts\n`); + + const sc = film.debug.sceneIds; + const varAddr = (name: string): number => { + const idx = film.debug.vars[name]; + if (idx === undefined) throw new Error("unknown var " + name); + return DBG.VARS + idx * 2; + }; + const tid = (s: string): number => { + const i = film.debug.texts.indexOf(s); + if (i < 0) throw new Error("text not found: " + s); + return i; + }; + + console.log("Scenario 1 — boot, title, 从头播放, chapter 1 beat"); + { + const r = await run([ + adv(60), + rd("magic", DBG.MAGIC, 4), + rd("booted", DBG.BOOTED), + rd("scene0", DBG.SCENE), + adv(170), + rd("waiting_menu", DBG.WAITING), + shot("e2e_title"), + A(), // 从头播放 + adv(80), + rd("scene1", DBG.SCENE), + adv(160), // dad dialog typing + A(), // dismiss dad dialog + adv(40), + rd("walk_wait", DBG.WAITING), + { op: "press", buttons: ["RIGHT"], frames: 140, release: 4 }, + rd("kidx", DBG.SPR0_X, 2), + adv(20), + A(), // (按下电源。) + adv(60), + A(), // 画画 caption + adv(30), + A(), + adv(200), // mosaic + fade + scene advance + rd("scene2", DBG.SCENE), + ]); + check("debug magic", r.magic >>> 0, DBG_MAGIC); + check("booted", r.booted, 1); + check("boots into title", r.scene0, sc.title); + check("title menu is a choice", r.waiting_menu, WAITING.CHOICE); + check("从头播放 -> paint486", r.scene1, sc.paint486); + check("control walk active", r.walk_wait, WAITING.CONTROL); + checkNear("kid walked to the 486", r.kidx, 168, 4); + check("chapter 1 auto-advances to aquarium", r.scene2, sc.aquarium); + } + + console.log("Scenario 2 — chapter select -> 一个URL: control + HN mash"); + { + const r = await run([ + ...pickChapter(false, 2), + adv(80), + rd("scene", DBG.SCENE), + adv(60), + A(), // book caption + adv(60), + { op: "press", buttons: ["RIGHT"], frames: 250, release: 4 }, + rd("evanx", DBG.SPR0_X, 2), + adv(60), + A(), // 小实验 caption + adv(80), + rd("mash_wait", DBG.WAITING), + rd("hn_before", varAddr("hn"), 2), + A(6), A(6), A(6), A(6), A(6), A(6), A(6), A(6), A(6), A(6), A(6), A(6), + rd("hn_after", varAddr("hn"), 2), + rd("after_mash_wait", DBG.WAITING), + shot("e2e_hn"), + ]); + check("chapter menu -> nyc", r.scene, sc.nyc); + checkNear("evan walked to the dorm", r.evanx, 330, 4); + check("mash waiting", r.mash_wait, WAITING.MASH); + check("hn starts at 88", r.hn_before, 88); + check("12 presses reach 100", r.hn_after, 100); + check("mash completes", r.after_mash_wait === WAITING.MASH ? 1 : 0, 0); + } + + console.log("Scenario 3 — 那封信: hesitate loop, then the jump, white into 星星"); + { + const r = await run([ + ...pickChapter(true, 0), + adv(100), + rd("scene", DBG.SCENE), + adv(120), + A(), // patreon caption + adv(120), + A(), // 信箱 dialog + adv(50), + A(), // 妻子 1 + adv(70), + A(), // 妻子 2 + adv(40), + rd("choice_wait", DBG.WAITING), + DOWN, + A(), // 再想想 + adv(60), + A(), // (雪停了) + adv(70), + A(), // caption + adv(30), + rd("loop_wait", DBG.WAITING), // choice again + A(), // 跳 + adv(30), + rd("jumped", varAddr("jumped"), 2), + rd("choice_val", DBG.LAST_CHOICE), + adv(260), // 他跳了 + white fade + rd("scene_after", DBG.SCENE), + shot("e2e_stars"), + ]); + check("chapter menu page 2 -> letter", r.scene, sc.letter); + check("the choice appears", r.choice_wait, WAITING.CHOICE); + check("再想想 loops back to the choice", r.loop_wait, WAITING.CHOICE); + check("跳 sets the flag", r.jumped, 1); + check("last choice recorded", r.choice_val, 0); + check("white fade chains into 星星", r.scene_after, sc.stars); + } + + console.log("Scenario 4 — OnePiece -> 闪电 -> 启航 chain"); + { + const r = await run([ + ...pickChapter(true, 2), + adv(80), + rd("scene", DBG.SCENE), + adv(140), + A(), // RFC caption + adv(60), + A(), // 风暴 dialog + adv(80), + A(), // 他 dialog + adv(160), // flag hoist + rd("flag_text", DBG.CUR_TEXT, 2), + A(), // one piece caption + adv(80), + rd("scene_vite", DBG.SCENE), + adv(200), + A(), // vite caption + adv(80), + A(), // npm create vite card -> 0.3s + adv(300), + A(), // 插上闪电 + adv(60), + A(), // 联合国 + adv(120), + rd("scene_fleet", DBG.SCENE), + ]); + check("chapter menu -> onepiece", r.scene, sc.onepiece); + check("One Piece caption shows", r.flag_text, tid("Vue 3 起航,代号:\nOne Piece。") + 1); + check("white flash chains into 闪电", r.scene_vite, sc.vite); + check("闪电 chains into 启航", r.scene_fleet, sc.fleet); + } + + console.log("Scenario 5 — 山丘与片尾 -> credits -> loop to title"); + { + const r = await run([ + ...pickChapter(true, 4), + adv(120), + rd("scene", DBG.SCENE), + adv(80), + A(), // 渐进式 caption + adv(500), // credit cards + rd("cred_text", DBG.CUR_TEXT, 2), + adv(220), + A(), // 下一集 + adv(60), + A(), // final card + adv(220), + rd("scene_back", DBG.SCENE), + rd("menu_wait_frames", DBG.FRAME, 2), + ]); + check("chapter menu -> coda", r.scene, sc.coda); + check( + "credits ticker running", + typeof r.cred_text === "number" && r.cred_text > 0 ? 1 : 0, + 1, + ); + check("coda loops back to title", r.scene_back, sc.title); + } + + console.log(`\n${failed === 0 ? "\x1b[32m" : "\x1b[31m"}${passed} passed, ${failed} failed\x1b[0m`); + process.exit(failed === 0 ? 0 : 1); +} + +await main(); diff --git a/cine/test/gen-placeholder-art.ts b/cine/test/gen-placeholder-art.ts new file mode 100644 index 0000000..008ddf0 --- /dev/null +++ b/cine/test/gen-placeholder-art.ts @@ -0,0 +1,78 @@ +// cine/test/gen-placeholder-art.ts — procedural PNGs for the smoke film so the +// pipeline is testable without PixelLab. + +import { encodePng } from "../compiler/png.ts"; + +const DIR = new URL("./art/", import.meta.url).pathname; + +function img(w: number, h: number, fn: (x: number, y: number) => [number, number, number, number]): Uint8Array { + const rgba = new Uint8Array(w * h * 4); + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const [r, g, b, a] = fn(x, y); + const i = (y * w + x) * 4; + rgba[i] = r; + rgba[i + 1] = g; + rgba[i + 2] = b; + rgba[i + 3] = a; + } + return encodePng(rgba, w, h); +} + +// main stage: 384x160 "street": ground + building blocks + lamp posts +await Bun.write( + DIR + "street.png", + img(384, 160, (x, y) => { + if (y > 128) return [70, 60, 56, 255]; // ground + if (y > 124) return [110, 100, 90, 255]; // curb + const block = Math.floor(x / 48); + const inBuilding = y > 40 + (block % 3) * 16 && x % 48 < 40; + if (inBuilding) { + const win = x % 8 < 4 && y % 12 < 6 && y > 56; + return win ? [230, 200, 120, 255] : [40 + (block % 4) * 12, 44, 60 + (block % 3) * 10, 255]; + } + return [0, 0, 0, 0]; // transparent -> sky shows + }), +); + +// far layer: rolling hill silhouettes (transparent above) +await Bun.write( + DIR + "hills.png", + img(240, 160, (x, y) => { + const ridge = 90 + Math.round(18 * Math.sin(x / 25) + 8 * Math.sin(x / 7)); + return y > ridge ? [24, 34, 52, 255] : [0, 0, 0, 0]; + }), +); + +// walker sprite: 32x32, 2 frames side by side +await Bun.write( + DIR + "walker.png", + img(64, 32, (x, y) => { + const f = x >= 32 ? 1 : 0; + const lx = x % 32; + // head + if (Math.hypot(lx - 16, y - 8) < 5) return [240, 200, 160, 255]; + // body + if (y >= 13 && y < 24 && lx >= 12 && lx < 20) return [66, 184, 131, 255]; + // legs alternate by frame + if (y >= 24 && y < 30) { + const spread = f ? 3 : 1; + if (Math.abs(lx - (16 - spread)) < 2 || Math.abs(lx - (16 + spread)) < 2) return [40, 48, 60, 255]; + } + return [0, 0, 0, 0]; + }), +); + +// emblem: 32x32 V mark +await Bun.write( + DIR + "emblem.png", + img(32, 32, (x, y) => { + const d1 = Math.abs(x - 6 - y * 0.4); + const d2 = Math.abs(x - 26 + y * 0.4); + if (y < 26 && (d1 < 2.5 || d2 < 2.5)) return [66, 184, 131, 255]; + if (y < 26 && (d1 < 4 || d2 < 4)) return [53, 73, 94, 255]; + return [0, 0, 0, 0]; + }), +); + +console.log("placeholder art written to cine/test/art/"); diff --git a/cine/test/ppm2png.ts b/cine/test/ppm2png.ts new file mode 100644 index 0000000..cf5017c --- /dev/null +++ b/cine/test/ppm2png.ts @@ -0,0 +1,34 @@ +// cine/test/ppm2png.ts — convert the harness's P6 PPM screenshots to PNG. +// bun test/ppm2png.ts dist/shots/*.ppm +import { encodePng } from "../compiler/png.ts"; + +export async function ppm2png(path: string): Promise { + const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()); + // P6\n \n255\n + let o = 0; + const token = (): string => { + while (bytes[o] === 32 || bytes[o] === 10 || bytes[o] === 13 || bytes[o] === 9) o++; + let s = ""; + while (o < bytes.length && ![32, 10, 13, 9].includes(bytes[o])) s += String.fromCharCode(bytes[o++]); + return s; + }; + if (token() !== "P6") throw new Error("not P6: " + path); + const w = parseInt(token()); + const h = parseInt(token()); + token(); // maxval + o++; // single whitespace after maxval + const rgba = new Uint8Array(w * h * 4); + for (let i = 0; i < w * h; i++) { + rgba[i * 4] = bytes[o + i * 3]; + rgba[i * 4 + 1] = bytes[o + i * 3 + 1]; + rgba[i * 4 + 2] = bytes[o + i * 3 + 2]; + rgba[i * 4 + 3] = 255; + } + const out = path.replace(/\.ppm$/, ".png"); + await Bun.write(out, encodePng(rgba, w, h)); + return out; +} + +if (import.meta.main) { + for (const p of process.argv.slice(2)) console.log(await ppm2png(p)); +} diff --git a/cine/test/smoke-film.ts b/cine/test/smoke-film.ts new file mode 100644 index 0000000..f6430cf --- /dev/null +++ b/cine/test/smoke-film.ts @@ -0,0 +1,86 @@ +// cine/test/smoke-film.ts — 2-scene pipeline smoke test (placeholder art). +// Exercises: gradient raster, parallax pan, sprite walk, captions (CJK+ASCII), +// dialog, choice branch, control walk, mash+counter, letterbox, mosaic, fades, +// affine zoom/spin, wave raster, scene transition, gotoScene loop guard. + +import { + defineFilm, defineScene, cue, image, gradient, sprite, + fadeIn, fadeOut, wait, waitA, waitTweens, caption, captionClear, dialog, choice, + pan, letterbox, mosaicTo, shake, alpha, zoom, spinTo, show, hide, animate, + moveTo, walkTo, control, mash, counter, affineOn, sfx, gotoScene, setFlag, hasFlag, + rasterWave, rasterOff, +} from "@pocketjs/cine"; + +const street = defineScene({ + id: "street", + sky: gradient("#0a1430", "#2a4a7a", "#e8965a"), + far: image("art/hills.png", { scroll: 0.35 }), + main: image("art/street.png", { wide: true }), + actors: { + walker: sprite("art/walker.png", { w: 32, h: 32, frames: 2, fps: 8, at: [60, 100] }), + }, + play: cue(function* () { + yield letterbox(16, 1); + yield fadeIn(30); + yield caption("chip", "1990年代 · 某条街"); + yield wait(20); + yield show("walker"); + yield letterbox(0, 30); + yield pan(144, 120, "inout"); + yield walkTo("walker", 240, 120); + yield caption("sub", "他沿着街走。Hello GBA!"); + yield waitA(); + yield captionClear("all"); + yield dialog("路人", "要不要自己走一段?"); + const c = yield choice(["好啊", "算了"]); + if (c === 0) { + yield setFlag("walked"); + yield control("walker", 330, 1.5); + } else { + yield walkTo("walker", 330, 90); + } + yield caption("sub", "按 A 收集星星!"); + yield counter("stars", 200, 24); + yield mash("stars", 5); + yield captionClear("all"); + yield shake(3, 40); + yield mosaicTo(12, 40); + yield fadeOut(30); + }), +}); + +const dream = defineScene({ + id: "dream", + sky: gradient("#050510", "#1a1035"), + backdrop: "#050510", + wave: { layer: "main", amp: 3 }, + main: image("art/hills.png"), + actors: { + emblem: sprite("art/emblem.png", { w: 32, h: 32, at: [120, 70], screen: true }), + }, + play: cue(function* () { + yield fadeIn(40); + yield caption("card", "梦境 DREAM"); + yield wait(30); + yield show("emblem", 112, 60); + yield affineOn("emblem"); + yield zoom(0.3, 1); + yield zoom(1.5, 60, "out"); + yield spinTo(360, 90, "inout"); + yield waitTweens(); + yield rasterOff(); + yield alpha(8, 8, 40); + yield waitA(); + if (yield hasFlag("walked")) { + yield caption("sub", "你走过了那条街。"); + } else { + yield caption("sub", "旁观也是一种走法。"); + } + yield waitA(); + yield captionClear("all"); + yield fadeOut(30); + yield gotoScene("street"); + }), +}); + +export default defineFilm({ title: "CINE SMOKE", scenes: [street, dream] }); diff --git a/edge/.gitignore b/edge/.gitignore new file mode 100644 index 0000000..4d2f0c3 --- /dev/null +++ b/edge/.gitignore @@ -0,0 +1,6 @@ +dist/ +runtime/gen_data.c +test/art/ +*.__edge.*.mjs +runtime/gen_music.s +game/music/*.raw diff --git a/edge/README.md b/edge/README.md new file mode 100644 index 0000000..8c0b9c0 --- /dev/null +++ b/edge/README.md @@ -0,0 +1,77 @@ +# @pocketjs/edge + +**Author interactive action-dramas in TypeScript; ship a real GBA ROM with run-and-gun stages, walkable worlds, cinematics — and a PCM insert song.** + +`@pocketjs/edge` is the action-genre descendant of `@pocketjs/saga` (which descends from `@pocketjs/cine`). It keeps the whole inherited vocabulary — Mode-0 four-layer parallax, per-scanline raster FX, BLDCNT fades, WIN0 letterbox, affine OBJs, typewriter captions (CJK + ASCII), top-down world scenes with NPC/trigger cues — and adds two new engine systems: + +- **A side-scrolling action core** (`runtime/action.c`): player physics (run/jump/shoot, rising diagonal shots, point-blank melee), a five-behavior enemy pool (thug / gunner / drone / turret / boss with telegraphed charges, spread volleys and ground shockwaves), a shared bullet pool, wave gates with checkpoints — and the **Sandevistan**: hold R and the world runs at 1/3 rate while you run full speed, the BG palette swaps to a compiler-tinted cyan duotone, and afterimage ghosts trail behind you. Stages never fail the film: death respawns you at the last gate and increments a story-visible `deaths` var. +- **DirectSound PCM music** (`runtime/audio.c`): s8 mono tracks at 13379 Hz (exactly 224 samples/frame — the VBlank counter is the stream clock) streamed straight from cartridge ROM by DMA1 into FIFO A on Timer 0, embedded at link time via `.incbin`. PSG sfx keep running on top. `yield music("stay")` in a cue starts a track; `music("off")` stops it. + +Everything is driven by the same partial-evaluation discipline: the declaration zone runs at build time, `cue(function* () { ... })` bodies are lowered from TS AST to bytecode for the suspendable cue VM. + +## The game + +**SEE YOU ON THE MOON —《月球见》** — an unaffiliated, non-commercial fan tribute to *Cyberpunk: Edgerunners* (Studio Trigger × CD Projekt RED). Original Chinese dialogue; the research dossier and every compression liberty is in `game/dossier.md`. Twelve scenes, action-first: + +1. **Title** — the moon over Night City. +2. **公寓** — Gloria, the crash, the ambulance that never landed. +3. **诊所** — "装上它。" The Sandevistan. +4. **ALLEY** *(action)* — tutorial ambush: run, jump, shoot, melee — and R. +5. **列车** *(world)* — a pickpocket in slow time. Lucy. +6. **据点** *(world)* — Maine's crew, one choice, one trial job. +7. **WAREHOUSE** *(action)* — guards, drones, a sentry turret, two gates. +8. **天台** ★ — the moon braindance, the promise — and the insert song, + an 8-bit cover of *"I Really Want to Stay at Your House"* streaming + off the cartridge while you talk. +9. **STREET** *(action)* — the Tanaka job falls apart. Maine's end. +10. **LAB** *(action)* — six months later; the cyberskeleton raid. +11. **TOWER** *(boss)* — Adam Smasher. Canon says you cannot win this + fight: at half HP the script takes over. +12. **月球** — Lucy, the quiet epilogue, credits, reprise. + +~15 minutes. Controls: D-pad move · A jump/confirm · B shoot (melee point-blank, ↑+B diagonal) · **R hold = Sandevistan** · in worlds: A talks/examines. + +## Build & run + +```bash +# prerequisites: bun, arm-none-eabi-gcc + binutils; mgba for the headless tests +cd edge +bun run art # (re)generate art via PixelLab (cached; needs PIXELLAB_API_KEY) +bun pixellab/walkers.ts # assemble top-down walker sheets +bun pixellab/actionsheets.ts # assemble side-view action sheets +bun run build # dist/see-you-on-the-moon.gba +bun run play # build + open in mGBA.app +bun run test:engine # engine E2E on the placeholder smoke film (40 asserts) +bun run test # game E2E: full playthrough via mgba +``` + +The insert-song `.raw` files under `game/music/` are **git-ignored** — they are +third-party 8-bit fan covers used for a private, non-commercial tribute only. +See `game/music/README.md` for the fetch + `ffmpeg -ac 1 -ar 13379 -f s8` step. +The engine tests use a procedurally-generated square-wave track, so +`bun run test:engine` needs none of them; `bun run test` (the full game build) +does. Do not distribute built ROMs that embed the covers. + +## Engine layout + +``` +spec/edge.ts binary contract: ops, action/stage tables, music tracks, + VRAM plan, debug block (mirrored to runtime/edge_gen.h) +dsl/index.ts defineFilm/defineScene (+world/+action decls) + cue vocabulary +compiler/ evaluate -> residualize -> assets (quantize, walker/action + sheets, sandevistan tinted palettes) -> gen_data.c + + gen_music.s (.incbin) -> rom +runtime/ fixed C: cue VM + world.c + action.c (physics, enemy AI, + bullets, sandevistan) + audio.c (DirectSound streaming) + + fx/raster/caption/obj/sfx + breakout.c (inherited) +pixellab/ pixflux client + game prompt sheet + walkers.ts + + actionsheets.ts (animate-with-text run/walk cycles) +game/ see-you-on-the-moon.ts + dossier.md + art/ + music/ +test/ engine-e2e.ts (40 asserts incl. action/music), game e2e +``` + +E2E drives the debug block at EWRAM `0x02000000` through `aot/test/harness/mgba_runner` — action fields: player world x, enemies alive, boss hp, sandevistan engaged, music track playing. + +## Fan-work notes + +Unaffiliated tribute; no trademarks or trade dress in generated art (prompts describe the cast in plain visual language). Real franchise names appear only in the story text of a private fan build. `game/dossier.md` carries the source list, the echoed-lines ledger, and the do-not-state-as-fact list. diff --git a/edge/compiler/assets.ts b/edge/compiler/assets.ts new file mode 100644 index 0000000..f195db9 --- /dev/null +++ b/edge/compiler/assets.ts @@ -0,0 +1,457 @@ +// edge/compiler/assets.ts — turn PNGs into GBA-native data: median-cut +// 15-color palettes, 4bpp tiles with H/V-flip dedup, tilemaps, OBJ sheets, +// per-scanline gradient tables, and the built-in UI tiles (box/cursor/digits/ +// A-prompt) rendered from Unifont. + +import { decodePng, type DecodedImage } from "./png.ts"; +import { unifontGlyph, halfcellPixels } from "./cjk.ts"; +import { rgb555, hex555, UI_INK, UI_BOX, UI_ACCENT, UI_SHADOW } from "../spec/edge.ts"; + +export interface Quantized { + /** palette[0] is transparent; entries 1..n are BGR555. */ + pal555: number[]; + indices: Uint8Array; // per pixel palette index (0 = transparent) + w: number; + h: number; +} + +export async function loadPng(path: string): Promise { + const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()); + return decodePng(bytes); +} + +/** Median-cut to <= maxColors opaque colors (+ index 0 transparent). */ +export function quantize(img: DecodedImage, maxColors = 15): Quantized { + const { width: w, height: h, rgba } = img; + const pixels: number[] = []; // packed rgb of opaque pixels + for (let i = 0; i < w * h; i++) { + if (rgba[i * 4 + 3] >= 128) pixels.push((rgba[i * 4] << 16) | (rgba[i * 4 + 1] << 8) | rgba[i * 4 + 2]); + } + // unique colors + const uniq = [...new Set(pixels)]; + let pal: number[]; + if (uniq.length <= maxColors) { + pal = uniq; + } else { + interface Box { colors: number[] } + const boxes: Box[] = [{ colors: uniq }]; + while (boxes.length < maxColors) { + // split the box with the largest channel range + let bi = -1; + let bch = 0; + let brange = -1; + for (let i = 0; i < boxes.length; i++) { + const cs = boxes[i].colors; + if (cs.length < 2) continue; + for (let ch = 0; ch < 3; ch++) { + const sh = 16 - ch * 8; + let lo = 255, hi = 0; + for (const c of cs) { + const v = (c >> sh) & 0xff; + if (v < lo) lo = v; + if (v > hi) hi = v; + } + if (hi - lo > brange) { + brange = hi - lo; + bi = i; + bch = sh; + } + } + } + if (bi < 0) break; + const cs = boxes[bi].colors.sort((a, b) => ((a >> bch) & 0xff) - ((b >> bch) & 0xff)); + const mid = cs.length >> 1; + boxes.splice(bi, 1, { colors: cs.slice(0, mid) }, { colors: cs.slice(mid) }); + } + // box average (weighted by pixel counts) + const counts = new Map(); + for (const p of pixels) counts.set(p, (counts.get(p) ?? 0) + 1); + pal = boxes.map(({ colors }) => { + let r = 0, g = 0, b = 0, n = 0; + for (const c of colors) { + const k = counts.get(c) ?? 1; + r += ((c >> 16) & 0xff) * k; + g += ((c >> 8) & 0xff) * k; + b += (c & 0xff) * k; + n += k; + } + return n ? ((Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n)) : 0; + }); + } + const pal555 = [0, ...pal.map((c) => rgb555((c >> 16) & 0xff, (c >> 8) & 0xff, c & 0xff))]; + // nearest-color mapping + const cache = new Map(); + const indices = new Uint8Array(w * h); + for (let i = 0; i < w * h; i++) { + if (rgba[i * 4 + 3] < 128) { + indices[i] = 0; + continue; + } + const c = (rgba[i * 4] << 16) | (rgba[i * 4 + 1] << 8) | rgba[i * 4 + 2]; + let best = cache.get(c); + if (best === undefined) { + let bd = Infinity; + best = 1; + for (let p = 0; p < pal.length; p++) { + const dr = ((c >> 16) & 0xff) - ((pal[p] >> 16) & 0xff); + const dg = ((c >> 8) & 0xff) - ((pal[p] >> 8) & 0xff); + const db = (c & 0xff) - (pal[p] & 0xff); + const d = dr * dr * 3 + dg * dg * 6 + db * db; + if (d < bd) { + bd = d; + best = p + 1; + } + } + cache.set(c, best); + } + indices[i] = best; + } + return { pal555, indices, w, h }; +} + +/** 4bpp tile from a 64-entry palette-index array. */ +export function tile4(px: ArrayLike): Uint8Array { + const out = new Uint8Array(32); + for (let row = 0; row < 8; row++) + for (let c = 0; c < 4; c++) { + out[row * 4 + c] = (px[row * 8 + c * 2] & 0xf) | ((px[row * 8 + c * 2 + 1] & 0xf) << 4); + } + return out; +} + +function tileKey(t: Uint8Array): string { + return Buffer.from(t).toString("base64"); +} +function flipH(px: number[]): number[] { + const o = new Array(64); + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) o[y * 8 + x] = px[y * 8 + (7 - x)]; + return o; +} +function flipV(px: number[]): number[] { + const o = new Array(64); + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) o[y * 8 + x] = px[(7 - y) * 8 + x]; + return o; +} + +export interface TiledLayer { + tiles: Uint8Array[]; // deduped, NOT including the shared blank tile 0 + /** map entries: tileIndex (1-based within this layer) | flip bits; 0 = blank */ + cells: { tile: number; hflip: boolean; vflip: boolean }[]; + cols: number; + rows: number; +} + +/** Cut an indexed image into deduped 8x8 tiles (H/V flip aware). */ +export function tileLayer(q: Quantized): TiledLayer { + const cols = Math.ceil(q.w / 8); + const rows = Math.ceil(q.h / 8); + const tiles: Uint8Array[] = []; + const seen = new Map(); + const cells: TiledLayer["cells"] = []; + for (let ty = 0; ty < rows; ty++) { + for (let tx = 0; tx < cols; tx++) { + const px = new Array(64).fill(0); + let allZero = true; + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + const sx = tx * 8 + x; + const sy = ty * 8 + y; + const v = sx < q.w && sy < q.h ? q.indices[sy * q.w + sx] : 0; + px[y * 8 + x] = v; + if (v) allZero = false; + } + if (allZero) { + cells.push({ tile: 0, hflip: false, vflip: false }); + continue; + } + const key = tileKey(tile4(px)); + const hit = seen.get(key); + if (hit) { + cells.push({ tile: hit.idx, hflip: hit.h, vflip: hit.v }); + continue; + } + const idx = tiles.length + 1; + tiles.push(tile4(px)); + seen.set(key, { idx, h: false, v: false }); + const fh = flipH(px); + const fv = flipV(px); + const fhv = flipV(fh); + for (const [p, hh, vv] of [ + [fh, true, false], + [fv, false, true], + [fhv, true, true], + ] as const) { + const k = tileKey(tile4(p)); + if (!seen.has(k)) seen.set(k, { idx, h: hh, v: vv }); + } + cells.push({ tile: idx, hflip: false, vflip: false }); + } + } + return { tiles, cells, cols, rows }; +} + +/** Build a screenblock map from a tiled layer. + * mapSz 0 = 32x32 (1 SBB), 1 = 64x32 (2 SBBs), 2 = 64x64 (4 SBBs TL,TR,BL,BR). */ +export function buildMap( + layer: TiledLayer, + mapSz: 0 | 1 | 2, + tileBase: number, + palbank: number, + rowOff = 0, +): Uint16Array { + const mapCols = mapSz ? 64 : 32; + const mapRows = mapSz === 2 ? 64 : 32; + const out = new Uint16Array(mapCols * mapRows); + for (let r0 = 0; r0 < layer.rows && r0 + rowOff < mapRows; r0++) { + const r = r0 + rowOff; + for (let c = 0; c < layer.cols && c < mapCols; c++) { + const cell = layer.cells[r0 * layer.cols + c]; + if (cell.tile === 0) continue; + let se = ((tileBase + cell.tile - 1) & 0x3ff) | ((palbank & 0xf) << 12); + if (cell.hflip) se |= 0x400; + if (cell.vflip) se |= 0x800; + // multi-screenblock layouts: quadrants of 32x32 + const sb = (r >> 5) * (mapCols >> 5) + (c >> 5); + out[sb * 1024 + (r & 31) * 32 + (c & 31)] = se; + } + } + return out; +} + +/** OBJ sheet: horizontal frame strip -> 4bpp tiles in 1D frame-major order. */ +export function tileObjSheet(q: Quantized, fw: number, fh: number, frames: number): Uint8Array { + const tw = fw / 8; + const th = fh / 8; + const out = new Uint8Array(frames * tw * th * 32); + let o = 0; + for (let f = 0; f < frames; f++) { + for (let ty = 0; ty < th; ty++) { + for (let tx = 0; tx < tw; tx++) { + const px = new Array(64).fill(0); + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + const sx = f * fw + tx * 8 + x; + const sy = ty * 8 + y; + if (sx < q.w && sy < q.h) px[y * 8 + x] = q.indices[sy * q.w + sx]; + } + out.set(tile4(px), o); + o += 32; + } + } + } + return out; +} + +/** 160-entry per-scanline gradient (multi-stop, lerp in RGB). */ +export function gradientTable(stops: string[]): Uint16Array { + const rgb = stops.map((s) => { + const h = s.replace("#", ""); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; + }); + const out = new Uint16Array(160); + const segs = rgb.length - 1; + for (let y = 0; y < 160; y++) { + const t = (y / 159) * segs; + const si = Math.min(segs - 1, Math.floor(t)); + const f = t - si; + const a = rgb[si]; + const b = rgb[si + 1]; + out[y] = rgb555( + Math.round(a[0] + (b[0] - a[0]) * f), + Math.round(a[1] + (b[1] - a[1]) * f), + Math.round(a[2] + (b[2] - a[2]) * f), + ); + } + return out; +} + +// --- built-in UI assets ----------------------------------------------------------- + +/** UI BG palette bank 15 colors (index -> BGR555). */ +export function uiPalette(): number[] { + const bank = new Array(16).fill(0); + bank[UI_INK] = hex555("#f2f5f7"); + bank[UI_BOX] = hex555("#141c2a"); + bank[UI_ACCENT] = hex555("#42b883"); + bank[UI_SHADOW] = hex555("#5a6478"); + return bank; +} + +/** 4 fixed BG tiles: blank, box fill, accent underline, choice cursor. */ +export function uiBgTiles(): Uint8Array { + const out = new Uint8Array(4 * 32); + // 0: blank (all transparent) + // 1: box fill + out.set(tile4(new Array(64).fill(UI_BOX)), 32); + // 2: accent underline: box with a 2px green line at the bottom + { + const px = new Array(64).fill(UI_BOX); + for (let x = 0; x < 8; x++) { + px[5 * 8 + x] = UI_ACCENT; + px[6 * 8 + x] = UI_ACCENT; + } + out.set(tile4(px), 64); + } + // 3: cursor arrow (ink on box) + { + const px = new Array(64).fill(UI_BOX); + for (let y = 1; y < 7; y++) { + const span = y < 4 ? y : 7 - y; + for (let x = 1; x <= 1 + span; x++) px[y * 8 + x] = UI_ACCENT; + } + out.set(tile4(px), 96); + } + return out; +} + +/** OBJ UI sheet layout (tile offsets from OBJ_UI_BASE): + * 0-3 A-prompt 16x16, 4-23 digits 0-9 as 8x16, 24 meter seg full, + * 25 meter seg empty, 26-27 brick 16x8, 28 ball 8x8, 29-32 paddle 32x8. */ +export function uiObjTiles(): Uint8Array { + const tiles: Uint8Array[] = []; + // A-prompt: dark disc, green rim, white 'A' + { + const grid = new Array(256).fill(0); + const cx = 7.5, cy = 7.5; + for (let y = 0; y < 16; y++) + for (let x = 0; x < 16; x++) { + const d = Math.hypot(x - cx, y - cy); + if (d <= 6.2) grid[y * 16 + x] = UI_BOX; + else if (d <= 7.6) grid[y * 16 + x] = UI_ACCENT; + } + const glyph = unifontGlyph(0x41); // 'A' + const [top, bottom] = halfcellPixels(glyph, 0, UI_INK, 99); + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + if (top[y * 8 + x] === UI_INK && y >= 2) grid[(y + 1) * 16 + (x + 4)] = UI_INK; + if (bottom[y * 8 + x] === UI_INK && y <= 5) grid[(y + 9) * 16 + (x + 4)] = UI_INK; + } + for (const [ox, oy] of [ + [0, 0], + [8, 0], + [0, 8], + [8, 8], + ]) { + const px = new Array(64); + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) px[y * 8 + x] = grid[(oy + y) * 16 + (ox + x)]; + tiles.push(tile4(px)); + } + } + // digits 0-9: unifont halfwidth, ink with shadow, transparent bg, 8x16 (2 tiles) + for (let d = 0; d <= 9; d++) { + const glyph = unifontGlyph(0x30 + d); + const [top, bottom] = halfcellPixels(glyph, 0, UI_INK, 0); + for (const half of [top, bottom]) { + // drop shadow: shift down-right in UI_SHADOW + const px = new Array(64).fill(0); + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + if (half[y * 8 + x] === UI_INK) px[y * 8 + x] = UI_INK; + } + tiles.push(tile4(px)); + } + } + // meter segments: full (accent fill, ink rim) and empty (shadow rim) + { + const full = new Array(64).fill(UI_ACCENT); + const empty = new Array(64).fill(0); + for (let i = 0; i < 8; i++) { + full[i] = full[56 + i] = UI_INK; + full[i * 8] = full[i * 8 + 7] = UI_INK; + empty[i] = empty[56 + i] = UI_SHADOW; + empty[i * 8] = empty[i * 8 + 7] = UI_SHADOW; + empty[i * 8 + 1] = empty[i * 8 + 1] || UI_BOX; + } + tiles.push(tile4(full), tile4(empty)); + } + // breakout brick 16x8: accent fill, ink top highlight, 1px gap right/bottom + for (const half of [0, 1]) { + const px = new Array(64).fill(UI_ACCENT); + for (let x = 0; x < 8; x++) { + px[x] = UI_INK; // top highlight + px[7 * 8 + x] = 0; // bottom gap + px[6 * 8 + x] = UI_SHADOW; + } + if (half === 1) { + for (let y = 0; y < 8; y++) px[y * 8 + 7] = 0; // right gap + } + tiles.push(tile4(px)); + } + // ball 8x8: ink disc + { + const px = new Array(64).fill(0); + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + const d = Math.hypot(x - 3.5, y - 3.5); + if (d <= 3.2) px[y * 8 + x] = UI_INK; + } + tiles.push(tile4(px)); + } + // paddle 32x8: shadow slab with ink top edge, rounded ends + for (let t = 0; t < 4; t++) { + const px = new Array(64).fill(0); + for (let y = 2; y < 7; y++) + for (let x = 0; x < 8; x++) { + const gx = t * 8 + x; + if ((gx === 0 || gx === 31) && (y === 2 || y === 6)) continue; + px[y * 8 + x] = y === 2 ? UI_INK : y === 6 ? UI_BOX : UI_SHADOW; + } + tiles.push(tile4(px)); + } + // 33: player bullet 8x8 — bright ink bolt with an accent tail + { + const px = new Array(64).fill(0); + for (let x = 2; x < 7; x++) px[3 * 8 + x] = px[4 * 8 + x] = UI_INK; + px[3 * 8 + 1] = px[4 * 8 + 1] = UI_ACCENT; + px[3 * 8 + 7] = px[4 * 8 + 7] = UI_ACCENT; + tiles.push(tile4(px)); + } + // 34: enemy bullet 8x8 — shadow-rimmed hostile pellet + { + const px = new Array(64).fill(0); + for (let y = 2; y < 6; y++) + for (let x = 2; x < 6; x++) px[y * 8 + x] = UI_SHADOW; + px[3 * 8 + 3] = px[3 * 8 + 4] = px[4 * 8 + 3] = px[4 * 8 + 4] = UI_INK; + tiles.push(tile4(px)); + } + // 35: impact spark 8x8 — 4-point accent star + { + const px = new Array(64).fill(0); + for (let i = 0; i < 8; i++) px[i * 8 + 3] = px[i * 8 + 4] = px[3 * 8 + i] = px[4 * 8 + i] = UI_ACCENT; + px[3 * 8 + 3] = px[3 * 8 + 4] = px[4 * 8 + 3] = px[4 * 8 + 4] = UI_INK; + tiles.push(tile4(px)); + } + // 36-37: ground shockwave 16x8 — low accent crest + for (const half of [0, 1]) { + const px = new Array(64).fill(0); + for (let x = 0; x < 8; x++) { + const gx = half * 8 + x; + const h = 2 + ((gx * 5) % 3); + for (let y = 8 - h; y < 8; y++) px[y * 8 + x] = y === 8 - h ? UI_INK : UI_ACCENT; + } + tiles.push(tile4(px)); + } + const out = new Uint8Array(tiles.length * 32); + tiles.forEach((t, i) => out.set(t, i * 32)); + return out; +} + +/** Sandevistan palette: desaturated cyan-teal duotone of a BG palette. The UI + * bank (15) is kept intact so captions/HUD stay readable while time is slow. */ +export function sandePalette(palBg: Uint16Array): Uint16Array { + const out = new Uint16Array(256); + for (let i = 0; i < 256; i++) { + if (i >= 15 * 16) { + out[i] = palBg[i]; + continue; + } + const c = palBg[i]; + const r = c & 31, gg = (c >> 5) & 31, b = (c >> 10) & 31; + const luma = (r * 5 + gg * 9 + b * 2) >> 4; + const nr = Math.min(31, (luma * 5) >> 4); + const ng = Math.min(31, ((luma * 13) >> 4) + 3); + const nb = Math.min(31, ((luma * 12) >> 4) + 4); + out[i] = nr | (ng << 5) | (nb << 10); + } + return out; +} diff --git a/edge/compiler/cjk.ts b/edge/compiler/cjk.ts new file mode 100644 index 0000000..96974cc --- /dev/null +++ b/edge/compiler/cjk.ts @@ -0,0 +1,89 @@ +// edge/compiler/cjk.ts — GNU Unifont loader (copied from aot; same font asset). +// +// Unifont ships every BMP glyph as a 16px-tall 1-bit bitmap in .hex format: +// one line per codepoint, "XXXX:", where halfwidth glyphs are 8px wide +// (32 hex digits) and fullwidth glyphs 16px wide (64 hex digits). That makes +// it the single cross-target glyph source: the compiler bakes only the glyphs +// a game actually uses, in each target's native tile encoding. +// +// Unifont is dual-licensed (GNU GPLv2+ with the font embedding exception / +// SIL OFL 1.1); embedding rendered glyphs in a ROM is explicitly permitted. + +import { gunzipSync } from "bun"; +import { readFileSync } from "node:fs"; + +const HEX_PATH = new URL("../../assets/fonts/unifont-16.0.04.hex.gz", import.meta.url).pathname; + +export interface UnifontGlyph { + /** 8 or 16 pixels wide; always 16 tall. */ + width: 8 | 16; + /** 16 rows; each row is a bitmask, MSB = leftmost pixel. */ + rows: Uint16Array; // length 16 +} + +let table: Map | null = null; + +function load(): Map { + if (table) return table; + const text = new TextDecoder().decode(gunzipSync(readFileSync(HEX_PATH))); + table = new Map(); + for (const line of text.split("\n")) { + const colon = line.indexOf(":"); + if (colon <= 0) continue; + const cp = parseInt(line.slice(0, colon), 16); + const hex = line.slice(colon + 1).trim(); + if (hex.length !== 32 && hex.length !== 64) continue; + const width = hex.length === 32 ? 8 : 16; + const rows = new Uint16Array(16); + const digitsPerRow = width / 4; + for (let r = 0; r < 16; r++) { + rows[r] = parseInt(hex.slice(r * digitsPerRow, (r + 1) * digitsPerRow), 16); + } + table.set(cp, { width: width as 8 | 16, rows }); + } + return table; +} + +/** Glyph for a codepoint, or null if Unifont has none. */ +export function unifontGlyph(cp: number): UnifontGlyph | null { + return load().get(cp) ?? null; +} + +/** True if the char renders fullwidth (2 halfcells). ASCII is halfwidth. */ +export function isFullwidth(ch: string): boolean { + const cp = ch.codePointAt(0)!; + if (cp <= 0x7e) return false; + const g = unifontGlyph(cp); + if (!g) return true; // unknown chars reserve a full cell (rendered blank) + return g.width === 16; +} + +/** + * Rasterize one halfcell (8x16 column) of a glyph into two stacked 8x8 + * palette-index tiles: [top 64 px, bottom 64 px], row-major. + * + * `half` selects the left (0) or right (1) 8px column of a 16px glyph. + * `ink`/`bg` are the palette indices to write. + */ +export function halfcellPixels( + glyph: UnifontGlyph | null, + half: 0 | 1, + ink: number, + bg: number, +): [number[], number[]] { + const top = new Array(64).fill(bg); + const bottom = new Array(64).fill(bg); + if (glyph) { + const shiftBase = glyph.width - 8 * (half + 1); // bits below the column + for (let y = 0; y < 16; y++) { + const row = glyph.rows[y]; + const dst = y < 8 ? top : bottom; + const dy = y & 7; + for (let x = 0; x < 8; x++) { + const bit = (row >> (shiftBase + 7 - x)) & 1; + if (bit) dst[dy * 8 + x] = ink; + } + } + } + return [top, bottom]; +} diff --git a/edge/compiler/cli.ts b/edge/compiler/cli.ts new file mode 100644 index 0000000..4b5b38e --- /dev/null +++ b/edge/compiler/cli.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env bun +// edge/compiler/cli.ts — `bun edge/compiler/cli.ts build film.tsx --out out.gba` + +import { compileFilm } from "./index.ts"; +import { emitGenData, emitGenMusic } from "./emit.ts"; +import { buildRom } from "./rom.ts"; + +const args = process.argv.slice(2); +if (args[0] !== "build" || !args[1]) { + console.error("usage: edge build [--out dist/film.gba] [--title TITLE]"); + process.exit(1); +} +const entry = args[1]; +const out = args.includes("--out") ? args[args.indexOf("--out") + 1] : new URL("../dist/film.gba", import.meta.url).pathname; +const title = args.includes("--title") ? args[args.indexOf("--title") + 1] : "EDGE"; + +const film = await compileFilm(entry); +const rom = await buildRom(emitGenData(film), out, title, emitGenMusic(film)); +await Bun.write(out + ".debug.json", JSON.stringify(film.debug, null, 2)); +console.log( + `edge: ${rom.gba} (${rom.size} bytes), ${film.scenes.length} scenes, ` + + `${film.debug.texts.length} texts, ${film.nHalfcells} glyph halfcells`, +); +for (const s of film.scenes) { + console.log( + ` scene ${s.id}: main ${s.nMain}t${s.wide ? " wide" : ""}, shared ${s.nShared}t, ` + + `obj ${s.objTiles.length / 32}t, cue ${s.cue.length}B`, + ); +} diff --git a/edge/compiler/emit.ts b/edge/compiler/emit.ts new file mode 100644 index 0000000..70b8013 --- /dev/null +++ b/edge/compiler/emit.ts @@ -0,0 +1,167 @@ +// edge/compiler/emit.ts — residualize the CompiledFilm into runtime/gen_data.c. +// Everything is plain C arrays in ROM; no container parsing at runtime. + +import type { CompiledFilm, CompiledScene } from "./index.ts"; + +/** gen_music.s — PCM tracks land in ROM via .incbin (a 1 MB C literal is silly). */ +export function emitGenMusic(film: CompiledFilm): string { + const lines: string[] = [ + "/* gen_music.s — GENERATED by edge/compiler. Do not edit. */", + ".section .rodata", + ]; + film.tracks.forEach((t, i) => { + lines.push(`.balign 4`, `.global mus_${i}`, `mus_${i}:`, `.incbin "${t.path}"`); + }); + lines.push(".balign 4", ""); + return lines.join("\n"); +} + +function u8arr(name: string, data: Uint8Array): string { + const lines: string[] = [`static const u8 ${name}[] = {`]; + for (let i = 0; i < data.length; i += 24) { + lines.push(" " + Array.from(data.subarray(i, i + 24)).join(",") + ","); + } + lines.push("};"); + return lines.join("\n"); +} + +function u16arr(name: string, data: Uint16Array | number[]): string { + const arr = Array.from(data); + const lines: string[] = [`static const u16 ${name}[] = {`]; + for (let i = 0; i < arr.length; i += 16) { + lines.push(" " + arr.slice(i, i + 16).map((v) => "0x" + v.toString(16)).join(",") + ","); + } + lines.push("};"); + return lines.join("\n"); +} + +function u32arr(name: string, data: number[]): string { + const lines: string[] = [`static const u32 ${name}[] = {`]; + for (let i = 0; i < data.length; i += 12) { + lines.push(" " + data.slice(i, i + 12).map((v) => String(v >>> 0)).join(",") + ","); + } + lines.push("};"); + return lines.join("\n"); +} + +export function emitGenData(film: CompiledFilm): string { + const parts: string[] = [ + "/* gen_data.c — GENERATED by edge/compiler. Do not edit. */", + '#include "edge.h"', + "", + ]; + + film.scenes.forEach((s: CompiledScene, i: number) => { + parts.push(u16arr(`s${i}_pal_bg`, s.palBg)); + parts.push(u16arr(`s${i}_pal_obj`, s.palObj)); + parts.push(u8arr(`s${i}_tiles_main`, s.tilesMain)); + if (s.nShared) parts.push(u8arr(`s${i}_tiles_shared`, s.tilesShared)); + parts.push(u16arr(`s${i}_map_main`, s.mapMain)); + if (s.mapFar) parts.push(u16arr(`s${i}_map_far`, s.mapFar)); + if (s.mapSky) parts.push(u16arr(`s${i}_map_sky`, s.mapSky)); + if (s.gradient) parts.push(u16arr(`s${i}_grad`, s.gradient)); + if (s.objTiles.length) parts.push(u8arr(`s${i}_obj`, s.objTiles)); + if (s.protos.length) { + const rows = s.protos + .map((p) => ` {${p.tileBase},${p.w},${p.h},${p.frames},${p.palbank},${p.fps},${p.walkFpd}},`) + .join("\n"); + parts.push(`static const EdgeProto s${i}_protos[] = {\n${rows}\n};`); + } + parts.push(u8arr(`s${i}_cue`, s.cue)); + parts.push(u16arr(`s${i}_cue_offs`, s.cueOffs)); + if (s.stage) { + const st = s.stage; + parts.push(u16arr(`s${i}_pal_sande`, st.palSande)); + if (st.spawns.length) { + const rows = st.spawns + .map((sp) => ` {${sp.x},${sp.proto},${sp.behavior},${sp.hp},${sp.wave}},`) + .join("\n"); + parts.push(`static const EdgeSpawn s${i}_spawns[] = {\n${rows}\n};`); + } + if (st.plats.length) { + const rows = st.plats.map((p) => ` {${p[0]},${p[1]},${p[2]}},`).join("\n"); + parts.push(`static const EdgePlat s${i}_plats[] = {\n${rows}\n};`); + } + if (st.gates.length) { + const rows = st.gates.map((gt) => ` {${gt[0]},${gt[1]}},`).join("\n"); + parts.push(`static const EdgeGate s${i}_gates[] = {\n${rows}\n};`); + } + parts.push( + `static const EdgeStage s${i}_stage = { ${st.playerProto}, ${st.hpMax}, ${st.sandeMax}, ` + + `${st.exitKind}, ${st.groundY}, ${st.length}, ` + + `${st.hpVar}, ${st.sandeVar}, ${st.killsVar}, ${st.deathsVar}, ` + + `${st.spawns.length ? `s${i}_spawns` : "0"}, ${st.spawns.length}, ` + + `${st.plats.length ? `s${i}_plats` : "0"}, ${st.plats.length}, ` + + `${st.gates.length ? `s${i}_gates` : "0"}, ${st.gates.length}, ` + + `${st.boss}, ${st.bossPhaseHp}, s${i}_pal_sande };`, + ); + } + if (s.world) { + const w = s.world; + parts.push(u8arr(`s${i}_solid`, w.solid)); + if (w.npcs.length) { + const rows = w.npcs + .map((n) => ` {${n.cx},${n.cy},${n.dir},${n.slot},${n.proto},${n.cue},${n.solid}},`) + .join("\n"); + parts.push(`static const EdgeNpc s${i}_npcs[] = {\n${rows}\n};`); + } + if (w.trigs.length) { + const rows = w.trigs + .map((t) => ` {${t.cx},${t.cy},${t.w},${t.h},${t.kind},${t.value},${t.cue}},`) + .join("\n"); + parts.push(`static const EdgeTrig s${i}_trigs[] = {\n${rows}\n};`); + } + parts.push( + `static const EdgeWorld s${i}_world = { ${w.cols}, ${w.rows}, s${i}_solid, ` + + `${w.startCx}, ${w.startCy}, ${w.startDir}, ${w.playerSlot}, ${w.playerProto}, ` + + `${w.npcs.length ? `s${i}_npcs` : "0"}, ${w.npcs.length}, ` + + `${w.trigs.length ? `s${i}_trigs` : "0"}, ${w.trigs.length} };`, + ); + } + parts.push(""); + }); + + parts.push(u32arr("film_text_offs", film.textOffs)); + parts.push(u8arr("film_text_blob", film.textBlob)); + parts.push(u8arr("film_glyphs", film.glyphs)); + parts.push(u8arr("film_ui_bg", film.uiBg)); + parts.push(u8arr("film_ui_obj", film.uiObj)); + if (film.tracks.length) { + film.tracks.forEach((_, i) => parts.push(`extern const s8 mus_${i}[]; /* gen_music.s */`)); + const rows = film.tracks.map((t, i) => ` {mus_${i}, ${t.samples}u, ${t.loop}},`).join("\n"); + parts.push(`static const EdgeTrack film_tracks[] = {\n${rows}\n};`); + } + parts.push(""); + + const rows = film.scenes.map((s, i) => { + const f = (b: boolean, t: string) => (b ? t : "0"); + return [ + " {", + `s${i}_pal_bg, s${i}_pal_obj,`, + `s${i}_tiles_main, ${s.nMain},`, + `${s.nShared ? `s${i}_tiles_shared` : "0"}, ${s.nShared},`, + `s${i}_map_main,`, + `${f(!!s.mapFar, `s${i}_map_far`)}, ${f(!!s.mapSky, `s${i}_map_sky`)},`, + `${s.mapSz},`, + `${s.farFacQ8}, ${s.skyFacQ8}, ${s.farVxQ8}, ${s.skyVxQ8},`, + `${f(!!s.gradient, `s${i}_grad`)},`, + `${s.objTiles.length ? `s${i}_obj` : "0"}, ${s.objTiles.length},`, + `${s.protos.length ? `s${i}_protos` : "0"}, ${s.protos.length},`, + `s${i}_cue, ${s.cue.length},`, + `s${i}_cue_offs, ${s.cueOffs.length},`, + `${s.kind}, ${f(!!s.world, `&s${i}_world`)}, ${f(!!s.stage, `&s${i}_stage`)},`, + `${s.cam0}, ${s.camMin}, ${s.camMax},`, + `${s.rasterMode}, ${s.rasterAmp}, ${s.letterbox0}, 0x${s.backdrop.toString(16)}`, + "},", + ].join(" "); + }); + parts.push(`static const EdgeScene film_scenes[] = {\n${rows.join("\n")}\n};`); + parts.push(""); + parts.push( + `const EdgeFilm film = { film_scenes, ${film.scenes.length}, film_text_offs, film_text_blob, ` + + `${film.textOffs.length}, film_glyphs, ${film.nHalfcells}, film_ui_bg, film_ui_obj, ${film.uiObj.length}, ` + + `${film.tracks.length ? "film_tracks" : "0"}, ${film.tracks.length} };`, + ); + parts.push(""); + return parts.join("\n"); +} diff --git a/edge/compiler/evaluate.ts b/edge/compiler/evaluate.ts new file mode 100644 index 0000000..2474818 --- /dev/null +++ b/edge/compiler/evaluate.ts @@ -0,0 +1,90 @@ +// edge/compiler/evaluate.ts — execute the declaration zone, capture cue ASTs. +// Same bridge as aot: every `cue(function*(){...})` argument is recorded and +// replaced by its id, then the rewritten module is transpiled and imported so +// the DSL builders fill the shared registry. Temp module names carry a +// per-process counter because Bun caches modules by path. + +import ts from "typescript"; +import { pathToFileURL } from "node:url"; + +const DSL_INDEX = new URL("../dsl/index.ts", import.meta.url).pathname; + +let evalCounter = 0; + +export interface CueSite { + id: number; + body: ts.FunctionExpression | ts.ArrowFunction; + file: ts.SourceFile; +} + +export interface EvalResult { + registry: import("../dsl/index.ts").Registry; + cues: CueSite[]; +} + +function findCueCalls(sf: ts.SourceFile): ts.CallExpression[] { + const out: ts.CallExpression[] = []; + const visit = (n: ts.Node): void => { + if ( + ts.isCallExpression(n) && + ts.isIdentifier(n.expression) && + n.expression.text === "cue" && + n.arguments.length === 1 + ) { + out.push(n); + return; + } + ts.forEachChild(n, visit); + }; + visit(sf); + out.sort((a, b) => a.getStart() - b.getStart()); + return out; +} + +export async function evaluateFilm(entryPath: string): Promise { + const source = await Bun.file(entryPath).text(); + const sf = ts.createSourceFile(entryPath, source, ts.ScriptTarget.ES2020, true, ts.ScriptKind.TS); + + const calls = findCueCalls(sf); + const cues: CueSite[] = []; + const edits: { start: number; end: number; text: string }[] = []; + calls.forEach((call, id) => { + const arg = call.arguments[0]; + if (!ts.isFunctionExpression(arg) && !ts.isArrowFunction(arg)) { + throw new Error(`cue() argument must be a function expression (cue #${id})`); + } + cues.push({ id, body: arg, file: sf }); + edits.push({ start: arg.getStart(sf), end: arg.getEnd(), text: String(id) }); + }); + edits.sort((a, b) => b.start - a.start); + let rewritten = source; + for (const e of edits) rewritten = rewritten.slice(0, e.start) + e.text + rewritten.slice(e.end); + rewritten = rewritten.replace(/(["'])@pocketjs\/edge\1/g, JSON.stringify(DSL_INDEX)); + + const js = ts.transpileModule(rewritten, { + fileName: entryPath, + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + + const dsl = (await import(DSL_INDEX)) as typeof import("../dsl/index.ts"); + dsl.__resetRegistry(); + + const tmp = entryPath + `.__edge.${process.pid}.${evalCounter++}.mjs`; + await Bun.write(tmp, js); + try { + await import(pathToFileURL(tmp).href); + } finally { + await Bun.file(tmp) + .exists() + .then((e) => (e ? Bun.$`rm -f ${tmp}`.quiet() : null)) + .catch(() => {}); + } + + const registry = dsl.__getRegistry(); + if (!registry.film) throw new Error("no defineFilm() found in " + entryPath); + return { registry, cues }; +} diff --git a/edge/compiler/index.ts b/edge/compiler/index.ts new file mode 100644 index 0000000..bf9f5cc --- /dev/null +++ b/edge/compiler/index.ts @@ -0,0 +1,655 @@ +// edge/compiler/index.ts — compileFilm(entry): evaluate the declaration zone, +// bake assets, residualize cues, and assemble the CompiledFilm model that +// emit.ts turns into gen_data.c. + +import { dirname, resolve } from "node:path"; +import { evaluateFilm } from "./evaluate.ts"; +import { residualizeCue, type CueCtx } from "./residualize.ts"; +import { TextBank } from "./text.ts"; +import { + loadPng, quantize, tileLayer, buildMap, tileObjSheet, gradientTable, + uiPalette, uiBgTiles, uiObjTiles, sandePalette, type Quantized, +} from "./assets.ts"; +import { + FARSKY_BASE, FARSKY_MAX, MAIN_TILE_MAX, GLYPH_SLOTS, + PALBANK_SKY, PALBANK_FAR, PALBANK_MAIN, PALBANK_UI, PALBANK_OBJ_UI, + MAX_SPRITES, MAX_SCENES, hex555, UI_INK, UI_BOX, + SCENE_CINE, SCENE_WORLD, SCENE_ACTION, CELL_PX, WORLD_COLS_MAX, WORLD_ROWS_MAX, + MAX_NPCS, MAX_TRIGS, MAX_CUES, TRIG_EXIT, TRIG_EXAMINE, TRIG_AUTO, + DIR_DOWN, DIR_UP, DIR_LEFT, DIR_RIGHT, + EB_THUG, EB_GUNNER, EB_DRONE, EB_TURRET, EB_BOSS, + MAX_SPAWNS, MAX_PLATS, MAX_GATES, MAX_TRACKS, + AEXIT_END, AEXIT_CLEAR, PLAYER_FRAMES, ENEMY_FRAMES, N_VARS, +} from "../spec/edge.ts"; +import type { + ActionDecl, ActorDecl, CellRef, CueRef, LayerDecl, RectRef, SceneDecl, TrackDecl, WorldDecl, +} from "../dsl/index.ts"; + +export interface CompiledProto { + tileBase: number; + w: number; + h: number; + frames: number; + palbank: number; + fps: number; + walkFpd: number; +} + +export interface CompiledNpc { + cx: number; + cy: number; + dir: number; + slot: number; + proto: number; + cue: number; // 0xff none + solid: number; +} + +export interface CompiledTrig { + cx: number; + cy: number; + w: number; + h: number; + kind: number; + value: number; + cue: number; // 0xff none +} + +export interface CompiledWorld { + cols: number; + rows: number; + solid: Uint8Array; + startCx: number; + startCy: number; + startDir: number; + playerSlot: number; + playerProto: number; + npcs: CompiledNpc[]; + trigs: CompiledTrig[]; +} + +export interface CompiledSpawn { + x: number; + proto: number; + behavior: number; + hp: number; + wave: number; +} + +export interface CompiledStage { + playerProto: number; + hpMax: number; + sandeMax: number; + exitKind: number; + groundY: number; + length: number; + hpVar: number; + sandeVar: number; + killsVar: number; + deathsVar: number; + spawns: CompiledSpawn[]; + plats: [number, number, number][]; + gates: [number, number][]; + boss: number; // spawn index, 0xff none + bossPhaseHp: number; + palSande: Uint16Array; +} + +export interface CompiledTrack { + name: string; + path: string; // absolute; emitted via .incbin + samples: number; + loop: number; +} + +export interface CompiledScene { + id: string; + palBg: Uint16Array; // 256 + palObj: Uint16Array; // 256 + tilesMain: Uint8Array; + nMain: number; + tilesShared: Uint8Array; + nShared: number; + mapMain: Uint16Array; + mapFar: Uint16Array | null; + mapSky: Uint16Array | null; + mapSz: 0 | 1 | 2; + kind: number; + world: CompiledWorld | null; + stage: CompiledStage | null; + cueOffs: number[]; + farFacQ8: number; + skyFacQ8: number; + farVxQ8: number; + skyVxQ8: number; + gradient: Uint16Array | null; + objTiles: Uint8Array; + protos: CompiledProto[]; + cue: Uint8Array; + cam0: number; + camMin: number; + camMax: number; + rasterMode: number; + rasterAmp: number; + letterbox0: number; + backdrop: number; +} + +export interface CompiledFilm { + title: string; + scenes: CompiledScene[]; + textOffs: number[]; + textBlob: Uint8Array; + glyphs: Uint8Array; + nHalfcells: number; + uiBg: Uint8Array; + uiObj: Uint8Array; + tracks: CompiledTrack[]; + debug: { + sceneIds: Record; + texts: string[]; + vars: Record; + flags: Record; + tracks: Record; + }; +} + +const RASTER_OFF = 0, RASTER_GRADIENT = 1, RASTER_WAVE_MAIN = 2, RASTER_WAVE_FAR = 3; + +export async function compileFilm(entryPath: string): Promise { + const entry = resolve(entryPath); + const base = dirname(entry); + const { registry, cues } = await evaluateFilm(entry); + const film = registry.film!; + if (film.scenes.length === 0) throw new Error("film has no scenes"); + if (film.scenes.length > MAX_SCENES) throw new Error(`too many scenes (max ${MAX_SCENES})`); + + const sceneIndex = new Map(); + film.scenes.forEach((s, i) => { + if (sceneIndex.has(s.id)) throw new Error(`duplicate scene id ${s.id}`); + sceneIndex.set(s.id, i); + }); + + const texts = new TextBank(); + const vars = new Map(); + const flags = new Map(); + + // PCM insert songs: validated here, embedded by emit via .incbin + const tracks: CompiledTrack[] = []; + const trackIndex = new Map(); + for (const [name, t] of Object.entries(film.music ?? {})) { + if ((t as TrackDecl).kind !== "track") throw new Error(`music ${name}: use track("file.raw")`); + if (tracks.length >= MAX_TRACKS) throw new Error(`too many music tracks (max ${MAX_TRACKS})`); + const p = resolve(base, t.raw); + const f = Bun.file(p); + if (!(await f.exists())) throw new Error(`music track ${name}: ${p} not found`); + if (f.size < 4) throw new Error(`music track ${name}: empty`); + trackIndex.set(name, tracks.length); + tracks.push({ name, path: p, samples: f.size, loop: t.loop ? 1 : 0 }); + } + + const scenes: CompiledScene[] = []; + for (const decl of film.scenes) { + scenes.push(await compileScene(decl, base, { texts, vars, flags, sceneIndex, cues, trackIndex })); + } + + const { offs, blob } = texts.buildBlob(); + const glyphs = texts.bakeGlyphStore(UI_INK, UI_BOX); + + return { + title: film.title, + scenes, + textOffs: offs, + textBlob: blob, + glyphs, + nHalfcells: glyphs.length / 64, + uiBg: uiBgTiles(), + uiObj: uiObjTiles(), + tracks, + debug: { + sceneIds: Object.fromEntries(sceneIndex), + texts: texts.entries.map((e) => e.raw), + vars: Object.fromEntries(vars), + flags: Object.fromEntries(flags), + tracks: Object.fromEntries(trackIndex), + }, + }; +} + +interface SceneEnv { + texts: TextBank; + vars: Map; + flags: Map; + sceneIndex: Map; + cues: import("./evaluate.ts").CueSite[]; + trackIndex: Map; +} + +async function loadLayer(base: string, layer: LayerDecl): Promise { + const img = await loadPng(resolve(base, layer.png)); + return quantize(img, 15); +} + +const DIR_NUM: Record = { down: DIR_DOWN, up: DIR_UP, left: DIR_LEFT, right: DIR_RIGHT }; + +function compileWorld( + sceneId: string, + wd: WorldDecl, + imgW: number, + imgH: number, + actors: CueCtx["actors"], + protos: CompiledProto[], + addCue: (name: string, ref: CueRef | undefined) => number, +): CompiledWorld { + const err = (msg: string): never => { + throw new Error(`[${sceneId}] world: ${msg}`); + }; + const rows = wd.grid.length; + const cols = Math.max(...wd.grid.map((r) => r.length)); + const expCols = Math.ceil(imgW / CELL_PX); + const expRows = Math.ceil(imgH / CELL_PX); + if (cols !== expCols || rows !== expRows) + err(`grid is ${cols}x${rows} cells but the main image is ${expCols}x${expRows} (${imgW}x${imgH}px)`); + if (cols > WORLD_COLS_MAX || rows > WORLD_ROWS_MAX) err(`grid too large (max ${WORLD_COLS_MAX}x${WORLD_ROWS_MAX})`); + + const solid = new Uint8Array(cols * rows); + const named = new Map(); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const ch = wd.grid[r][c] ?? "#"; + if (ch === "#") solid[r * cols + c] = 1; + else if (ch !== "." && ch !== " ") { + if (!named.has(ch)) named.set(ch, []); + named.get(ch)!.push([c, r]); + } + } + } + const cellOf = (at: CellRef, what: string): [number, number] => { + if (typeof at !== "string") return at; + const cells = named.get(at); + if (!cells) return err(`${what}: no cell '${at}' in grid`); + return cells[0]; + }; + const rectOf = (at: RectRef, what: string): [number, number, number, number] => { + if (typeof at !== "string") return at; + const cells = named.get(at); + if (!cells) return err(`${what}: no cell '${at}' in grid`); + const xs = cells.map((c) => c[0]); + const ys = cells.map((c) => c[1]); + const x0 = Math.min(...xs), y0 = Math.min(...ys); + return [x0, y0, Math.max(...xs) - x0 + 1, Math.max(...ys) - y0 + 1]; + }; + const actorOf = (name: string, what: string) => { + const a = actors.get(name); + if (!a) return err(`${what}: unknown actor "${name}"`); + return a; + }; + + const pa = actorOf(wd.player.actor, "player"); + if (!protos[pa.proto].walkFpd) err(`player actor "${wd.player.actor}" needs walkFpd`); + const [scx, scy] = cellOf(wd.player.at, "player.at"); + + const npcs: CompiledNpc[] = []; + for (const [name, n] of Object.entries(wd.npcs ?? {})) { + if (npcs.length >= MAX_NPCS) err(`too many npcs (max ${MAX_NPCS})`); + const a = actorOf(n.actor, `npc ${name}`); + const [cx, cy] = cellOf(n.at, `npc ${name}.at`); + npcs.push({ + cx, + cy, + dir: DIR_NUM[n.dir ?? "down"], + slot: a.slot, + proto: a.proto, + cue: addCue(`${sceneId}.${name}`, n.talk), + solid: n.solid === false ? 0 : 1, + }); + } + + const trigs: CompiledTrig[] = []; + const addTrig = (kind: number, name: string, at: RectRef, value: number, ref?: CueRef): void => { + if (trigs.length >= MAX_TRIGS) err(`too many triggers (max ${MAX_TRIGS})`); + const [cx, cy, w, h] = rectOf(at, name); + trigs.push({ cx, cy, w, h, kind, value, cue: addCue(`${sceneId}.${name}`, ref) }); + }; + for (const [name, e] of Object.entries(wd.exits ?? {})) addTrig(TRIG_EXIT, name, e.at, e.value); + for (const [name, s] of Object.entries(wd.spots ?? {})) addTrig(TRIG_EXAMINE, name, s.at, 0, s.run); + for (const [name, s] of Object.entries(wd.autos ?? {})) addTrig(TRIG_AUTO, name, s.at, 0, s.run); + + return { + cols, + rows, + solid, + startCx: scx, + startCy: scy, + startDir: DIR_NUM[wd.player.dir ?? "down"], + playerSlot: pa.slot, + playerProto: pa.proto, + npcs, + trigs, + }; +} + +const EB_NUM: Record = { + thug: EB_THUG, gunner: EB_GUNNER, drone: EB_DRONE, turret: EB_TURRET, boss: EB_BOSS, +}; + +function compileAction( + sceneId: string, + ad: ActionDecl, + imgW: number, + actors: CueCtx["actors"], + protos: CompiledProto[], + internVar: (name: string) => number, + palBg: Uint16Array, +): CompiledStage { + const err = (msg: string): never => { + throw new Error(`[${sceneId}] action: ${msg}`); + }; + const pa = actors.get(ad.player.actor); + if (!pa) return err(`unknown player actor "${ad.player.actor}"`); + if (pa.slot !== 0) return err(`player actor "${ad.player.actor}" must be the first declared actor`); + if (protos[pa.proto].frames < PLAYER_FRAMES) + return err(`player sheet needs ${PLAYER_FRAMES} frames (idle, run x4, jump, shoot, hurt)`); + + const length = ad.length ?? imgW; + if (length < 240) return err(`stage length ${length} < 240`); + if (ad.spawns.length > MAX_SPAWNS) return err(`too many spawns (max ${MAX_SPAWNS})`); + if ((ad.platforms ?? []).length > MAX_PLATS) return err(`too many platforms (max ${MAX_PLATS})`); + if ((ad.gates ?? []).length > MAX_GATES) return err(`too many gates (max ${MAX_GATES})`); + + const order = ad.spawns.map((s, i) => i).sort((a, b) => ad.spawns[a].x - ad.spawns[b].x); + let boss = 0xff; + const spawns: CompiledSpawn[] = order.map((oi, i) => { + const s = ad.spawns[oi]; + const a = actors.get(s.actor); + if (!a) return err(`spawn ${oi}: unknown actor "${s.actor}"`); + if (protos[a.proto].frames < ENEMY_FRAMES) + return err(`spawn actor "${s.actor}" needs ${ENEMY_FRAMES} frames (idle, walk x2, attack)`); + const behavior = EB_NUM[s.type]; + if (behavior === undefined) return err(`spawn ${oi}: unknown type "${s.type}"`); + if (s.type === "boss") { + if (boss !== 0xff) return err("only one boss per stage"); + boss = i; + } + if (s.x < 0 || s.x >= length) return err(`spawn ${oi} x ${s.x} outside stage [0, ${length})`); + return { x: s.x, proto: a.proto, behavior, hp: s.hp ?? (s.type === "boss" ? 40 : 2), wave: s.wave ?? 0 }; + }); + + const gates: [number, number][] = (ad.gates ?? []) + .map((gt): [number, number] => [gt.x, gt.wave]) + .sort((a, b) => a[0] - b[0]); + if (ad.bossPhaseHp !== undefined && boss === 0xff) return err("bossPhaseHp without a boss spawn"); + + return { + playerProto: pa.proto, + hpMax: ad.player.hp ?? 6, + sandeMax: ad.player.sande ?? 0, + exitKind: ad.exit === "clear" ? AEXIT_CLEAR : AEXIT_END, + groundY: ad.ground, + length, + hpVar: internVar((ad.vars?.hp ?? "hp")), + sandeVar: internVar((ad.vars?.sande ?? "sande")), + killsVar: internVar((ad.vars?.kills ?? "kills")), + deathsVar: internVar((ad.vars?.deaths ?? "deaths")), + spawns, + plats: ad.platforms ?? [], + gates, + boss, + bossPhaseHp: ad.bossPhaseHp ?? 0, + palSande: sandePalette(palBg), + }; +} + +async function compileScene(decl: SceneDecl, base: string, env: SceneEnv): Promise { + const palBg = new Uint16Array(256); + const palObj = new Uint16Array(256); + + // UI palettes (BG bank 15 + OBJ bank 15) + uiPalette().forEach((c, i) => { + palBg[PALBANK_UI * 16 + i] = c; + palObj[PALBANK_OBJ_UI * 16 + i] = c; + }); + + const backdrop = hex555(decl.backdrop ?? "#000000"); + palBg[0] = backdrop; + + // --- main layer -> charblock 0 ------------------------------------------------ + const isWorld = !!decl.world; + let tilesMain = new Uint8Array(32); // tile 0 blank + let nMain = 1; + let mapMain = new Uint16Array(1024); + let mapSz: 0 | 1 | 2 = 0; + let imgW = 240; + let imgH = 160; + if (isWorld && !decl.main) throw new Error(`[${decl.id}] world scenes need a main image`); + if (decl.main) { + const q = await loadLayer(base, decl.main); + imgW = q.w; + imgH = q.h; + mapSz = isWorld ? 2 : q.w > 240 ? 1 : 0; + if (q.w > 512) throw new Error(`[${decl.id}] main image too wide (max 512): ${q.w}`); + if (q.h > (mapSz === 2 ? 512 : 256)) throw new Error(`[${decl.id}] main image too tall: ${q.h}`); + const tl = tileLayer(q); + if (1 + tl.tiles.length > MAIN_TILE_MAX) throw new Error(`[${decl.id}] main tiles ${tl.tiles.length} > budget`); + tilesMain = new Uint8Array((1 + tl.tiles.length) * 32); + tl.tiles.forEach((t, i) => tilesMain.set(t, (1 + i) * 32)); + nMain = 1 + tl.tiles.length; + mapMain = buildMap(tl, mapSz, 1, PALBANK_MAIN); + q.pal555.forEach((c, i) => { + if (i > 0) palBg[PALBANK_MAIN * 16 + i] = c; + }); + } + if (isWorld && (decl.far || decl.sky?.kind === "image")) + throw new Error(`[${decl.id}] world scenes cannot have far/sky layers (their screenblocks hold the map)`); + + // --- far + sky -> shared charblock ----------------------------------------------- + const sharedTiles: Uint8Array[] = []; + let mapFar: Uint16Array | null = null; + let mapSky: Uint16Array | null = null; + let gradient: Uint16Array | null = null; + let farFacQ8 = Math.round((decl.far?.scroll ?? 0.4) * 256); + let skyFacQ8 = 0; + const farVxQ8 = Math.round((decl.far?.vx ?? 0) * 256); + let skyVxQ8 = 0; + + if (decl.far) { + const q = await loadLayer(base, decl.far); + const tl = tileLayer(q); + const tileBase = FARSKY_BASE + sharedTiles.length; + sharedTiles.push(...tl.tiles); + mapFar = buildMap(tl, 0, tileBase, PALBANK_FAR, (decl.far.y ?? 0) >> 3); + q.pal555.forEach((c, i) => { + if (i > 0) palBg[PALBANK_FAR * 16 + i] = c; + }); + } + if (decl.sky) { + if (decl.sky.kind === "gradient") { + gradient = gradientTable(decl.sky.stops); + } else { + const q = await loadPng(resolve(base, decl.sky.png)).then((img) => quantize(img, 15)); + const tl = tileLayer(q); + const tileBase = FARSKY_BASE + sharedTiles.length; + sharedTiles.push(...tl.tiles); + mapSky = buildMap(tl, 0, tileBase, PALBANK_SKY, (decl.sky.y ?? 0) >> 3); + q.pal555.forEach((c, i) => { + if (i > 0) palBg[PALBANK_SKY * 16 + i] = c; + }); + skyFacQ8 = Math.round((decl.sky.scroll ?? 0.15) * 256); + skyVxQ8 = Math.round((decl.sky.vx ?? 0) * 256); + } + } + if (sharedTiles.length > FARSKY_MAX) throw new Error(`[${decl.id}] far+sky tiles ${sharedTiles.length} > ${FARSKY_MAX}`); + const tilesShared = new Uint8Array(sharedTiles.length * 32); + sharedTiles.forEach((t, i) => tilesShared.set(t, i * 32)); + + // --- actors -> protos + OBJ sheet ---------------------------------------------------- + const actorEntries = Object.entries(decl.actors ?? {}); + if (actorEntries.length > MAX_SPRITES) throw new Error(`[${decl.id}] too many actors (max ${MAX_SPRITES})`); + const protos: CompiledProto[] = []; + const protoByPng = new Map(); + const objParts: Uint8Array[] = []; + let objTileCursor = 0; + const actors: CueCtx["actors"] = new Map(); + let nextObjBank = 0; + + for (const [name, a] of actorEntries) { + const key = `${a.png}|${a.w}x${a.h}x${a.frames ?? 1}`; + let protoIdx = protoByPng.get(key); + if (protoIdx === undefined) { + const img = await loadPng(resolve(base, a.png)); + const frames = a.frames ?? 1; + if (img.width < a.w * frames || img.height < a.h) { + throw new Error(`[${decl.id}] sprite ${a.png}: ${img.width}x${img.height} < ${a.w * frames}x${a.h}`); + } + const q = quantize(img, 15); + if (nextObjBank >= PALBANK_OBJ_UI) throw new Error(`[${decl.id}] too many OBJ palettes`); + const bank = nextObjBank++; + q.pal555.forEach((c, i) => { + if (i > 0) palObj[bank * 16 + i] = c; + }); + const sheet = tileObjSheet(q, a.w, a.h, frames); + if (a.walkFpd && frames < 3 * a.walkFpd) { + throw new Error( + `[${decl.id}] walker ${a.png}: needs ${3 * a.walkFpd} frames (3 rows x walkFpd), has ${frames}`, + ); + } + protoIdx = protos.length; + protos.push({ + tileBase: objTileCursor, + w: a.w, + h: a.h, + frames, + palbank: bank, + fps: a.fps ?? 10, + walkFpd: a.walkFpd ?? 0, + }); + objTileCursor += sheet.length / 32; + objParts.push(sheet); + protoByPng.set(key, protoIdx); + } + actors.set(name, { slot: actors.size, proto: protoIdx, decl: a }); + } + if (objTileCursor > 1000) throw new Error(`[${decl.id}] OBJ tiles ${objTileCursor} > 1000 budget`); + const objTiles = new Uint8Array(objParts.reduce((n, p) => n + p.length, 0)); + { + let o = 0; + for (const p of objParts) { + objTiles.set(p, o); + o += p.length; + } + } + + // --- world (top-down grid) -------------------------------------------------------- + // The cue table: cue 0 = play; NPC talk / spot / auto cues follow. + const cueRefs: { name: string; ref: CueRef }[] = []; + const addCue = (name: string, ref: CueRef | undefined): number => { + if (!ref) return 0xff; + if (typeof (ref as { __cue?: number }).__cue !== "number") + throw new Error(`[${decl.id}] ${name}: expected cue(function* () { ... })`); + if (cueRefs.length >= MAX_CUES) throw new Error(`[${decl.id}] too many cues (max ${MAX_CUES})`); + cueRefs.push({ name, ref }); + return cueRefs.length - 1; + }; + if (!decl.play || typeof (decl.play as { __cue?: number }).__cue !== "number") { + throw new Error(`[${decl.id}] scene has no play: cue(...)`); + } + addCue(decl.id, decl.play); + + let world: CompiledWorld | null = null; + if (decl.world) { + world = compileWorld(decl.id, decl.world, imgW, imgH, actors, protos, addCue); + } + + let stage: CompiledStage | null = null; + if (decl.action) { + if (decl.world) throw new Error(`[${decl.id}] a scene cannot be both world and action`); + const internVar = (name: string): number => { + let id = env.vars.get(name); + if (id === undefined) { + id = env.vars.size; + if (id >= N_VARS) throw new Error(`too many vars (max ${N_VARS}): ${name}`); + env.vars.set(name, id); + } + return id; + }; + stage = compileAction(decl.id, decl.action, imgW, actors, protos, internVar, palBg); + } + + const cueOffs: number[] = []; + const cueParts: Uint8Array[] = []; + let cueLen = 0; + for (const { name, ref } of cueRefs) { + const site = env.cues.find((c) => c.id === (ref as { __cue: number }).__cue); + if (!site) throw new Error(`[${decl.id}] cue site not found for ${name}`); + const bytes = residualizeCue( + site.body, + { + texts: env.texts, + vars: env.vars, + flags: env.flags, + sceneIndex: env.sceneIndex, + actors, + cueName: name, + trackIndex: env.trackIndex, + }, + cueLen, // jump targets are blob-absolute + ); + cueOffs.push(cueLen); + cueParts.push(bytes); + cueLen += bytes.length; + } + const cue = new Uint8Array(cueLen); + { + let o = 0; + for (const p of cueParts) { + cue.set(p, o); + o += p.length; + } + } + + // --- camera + raster defaults ---------------------------------------------------------- + const camMin = decl.camera?.min ?? 0; + let camMax = decl.camera?.max ?? Math.max(0, imgW - 240); + if (stage) camMax = Math.max(camMax, stage.length - 240); // virtual stages outrun their art + const cam0 = decl.camera?.start ?? camMin; + let rasterMode = RASTER_OFF; + let rasterAmp = 0; + if (gradient) rasterMode = RASTER_GRADIENT; + if (decl.wave) { + rasterMode = decl.wave.layer === "far" ? RASTER_WAVE_FAR : RASTER_WAVE_MAIN; + rasterAmp = decl.wave.amp; + } + + return { + id: decl.id, + palBg, + palObj, + tilesMain, + nMain, + tilesShared, + nShared: sharedTiles.length, + mapMain, + mapFar, + mapSky, + mapSz, + kind: isWorld ? SCENE_WORLD : decl.action ? SCENE_ACTION : SCENE_CINE, + world, + stage, + cueOffs, + farFacQ8, + skyFacQ8, + farVxQ8, + skyVxQ8, + gradient, + objTiles, + protos, + cue, + cam0, + camMin, + camMax, + rasterMode, + rasterAmp, + letterbox0: decl.letterbox ?? 0, + backdrop, + }; +} diff --git a/edge/compiler/png.ts b/edge/compiler/png.ts new file mode 100644 index 0000000..02ef974 --- /dev/null +++ b/edge/compiler/png.ts @@ -0,0 +1,155 @@ +// edge/compiler/png.ts — minimal PNG codec (build-time only), self-contained so +// @pocketjs/edge stays independent. Decoder adapted from compiler/pak.ts, +// encoder from scripts/psp-all.ts. + +import { inflateSync, deflateSync } from "node:zlib"; + +export interface DecodedImage { + width: number; + height: number; + /** RGBA, 4 bytes/px, row-major. */ + rgba: Uint8Array; +} + +/** Decode an 8-bit RGB/RGBA/grayscale non-interlaced PNG to RGBA. */ +export function decodePng(bytes: Uint8Array): DecodedImage { + const SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + for (let i = 0; i < 8; i++) { + if (bytes[i] !== SIG[i]) throw new Error("png: bad signature"); + } + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let o = 8; + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = 0; + const idat: Uint8Array[] = []; + while (o + 8 <= bytes.length) { + const len = dv.getUint32(o, false); + const type = String.fromCharCode(bytes[o + 4], bytes[o + 5], bytes[o + 6], bytes[o + 7]); + const body = bytes.subarray(o + 8, o + 8 + len); + if (type === "IHDR") { + width = dv.getUint32(o + 8, false); + height = dv.getUint32(o + 12, false); + bitDepth = bytes[o + 16]; + colorType = bytes[o + 17]; + if (bytes[o + 20] !== 0) throw new Error("png: interlaced PNGs unsupported"); + } else if (type === "IDAT") { + idat.push(body); + } else if (type === "IEND") { + break; + } + o += 12 + len; + } + if (bitDepth !== 8) throw new Error(`png: only 8-bit supported (got ${bitDepth})`); + const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colorType]; + if (!channels) throw new Error(`png: unsupported color type ${colorType} (palette PNGs: re-export as RGBA)`); + + const zdata = new Uint8Array(idat.reduce((n, c) => n + c.length, 0)); + let zo = 0; + for (const c of idat) { + zdata.set(c, zo); + zo += c.length; + } + const raw = new Uint8Array(inflateSync(zdata)); + + const stride = width * channels; + const rgba = new Uint8Array(width * height * 4); + const prev = new Uint8Array(stride); + const line = new Uint8Array(stride); + let ro = 0; + for (let y = 0; y < height; y++) { + const filter = raw[ro++]; + for (let x = 0; x < stride; x++) { + const cur = raw[ro + x]; + const a = x >= channels ? line[x - channels] : 0; + const b = prev[x]; + const c = x >= channels ? prev[x - channels] : 0; + let v: number; + switch (filter) { + case 0: v = cur; break; + case 1: v = cur + a; break; + case 2: v = cur + b; break; + case 3: v = cur + ((a + b) >> 1); break; + case 4: { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + v = cur + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c); + break; + } + default: throw new Error(`png: bad filter ${filter}`); + } + line[x] = v & 0xff; + } + ro += stride; + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + const s = x * channels; + switch (colorType) { + case 0: rgba[i] = rgba[i + 1] = rgba[i + 2] = line[s]; rgba[i + 3] = 255; break; + case 2: rgba[i] = line[s]; rgba[i + 1] = line[s + 1]; rgba[i + 2] = line[s + 2]; rgba[i + 3] = 255; break; + case 4: rgba[i] = rgba[i + 1] = rgba[i + 2] = line[s]; rgba[i + 3] = line[s + 1]; break; + case 6: rgba[i] = line[s]; rgba[i + 1] = line[s + 1]; rgba[i + 2] = line[s + 2]; rgba[i + 3] = line[s + 3]; break; + } + } + prev.set(line); + } + return { width, height, rgba }; +} + +// --- encoder ------------------------------------------------------------------- + +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +function crc32(buf: Uint8Array): number { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type: string, data: Uint8Array): Uint8Array { + const out = new Uint8Array(12 + data.length); + const dv = new DataView(out.buffer); + dv.setUint32(0, data.length, false); + for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i); + out.set(data, 8); + const body = out.subarray(4, 8 + data.length); + dv.setUint32(8 + data.length, crc32(body), false); + return out; +} + +export function encodePng(rgba: Uint8Array, w: number, h: number): Uint8Array { + const stride = w * 4; + const raw = new Uint8Array((stride + 1) * h); + for (let y = 0; y < h; y++) { + raw[y * (stride + 1)] = 0; + raw.set(rgba.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1); + } + const ihdr = new Uint8Array(13); + const dv = new DataView(ihdr.buffer); + dv.setUint32(0, w, false); + dv.setUint32(4, h, false); + ihdr[8] = 8; + ihdr[9] = 6; + const sig = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + const idat = new Uint8Array(deflateSync(raw)); + const parts = [sig, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0))]; + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.length; + } + return out; +} diff --git a/edge/compiler/residualize.ts b/edge/compiler/residualize.ts new file mode 100644 index 0000000..7d16c0e --- /dev/null +++ b/edge/compiler/residualize.ts @@ -0,0 +1,513 @@ +// edge/compiler/residualize.ts — lower cue generator ASTs to cue-VM bytecode. +// +// Recognized statement forms (anything else is a compile error, on purpose — +// residual code is a DSL, not JavaScript): +// yield op(...); op from the vocabulary table below +// const x = yield choice([...]); result var (also hasFlag/rnd/var*) +// if (cond) {...} else {...} cond: x === n | x < n | yield hasFlag() +// | yield varXx() | !cond | (cond) +// while (cond) {...} break; return; +// setVar/addVar/setFlag/clrFlag via yield like any op. + +import ts from "typescript"; +import { + OP, TW, ByteWriter, CMP_EQ, CMP_NE, CMP_LT, CMP_GT, CMP_LE, CMP_GE, + FADE_IN_BLACK, FADE_OUT_BLACK, FADE_IN_WHITE, FADE_OUT_WHITE, + RASTER_OFF, RASTER_GRADIENT, RASTER_WAVE_MAIN, RASTER_WAVE_FAR, + CAP_CHIP, CAP_SUB, CAP_CARD, + EASE_LINEAR, EASE_IN, EASE_OUT, EASE_INOUT, + SFX_BLIP, SFX_CONFIRM, SFX_WHOOSH, SFX_STAR, + SPR_HFLIP, SPR_SCREEN, SPR_BEHIND, SPR_GHOST, + TW_SPRITE_BASE, N_VARS, N_FLAGS, MAX_CHOICES, + DIR_DOWN, DIR_UP, DIR_LEFT, DIR_RIGHT, BRICK_ROWS_MAX, +} from "../spec/edge.ts"; +import type { TextBank } from "./text.ts"; +import type { ActorDecl } from "../dsl/index.ts"; + +export interface CueCtx { + texts: TextBank; + vars: Map; + flags: Map; + sceneIndex: Map; + /** current scene's actors: name -> { slot, proto, decl } */ + actors: Map; + cueName: string; + /** film music tracks: name -> track id */ + trackIndex: Map; +} + +const EASES: Record = { linear: EASE_LINEAR, in: EASE_IN, out: EASE_OUT, inout: EASE_INOUT }; +const SFXS: Record = { blip: SFX_BLIP, confirm: SFX_CONFIRM, whoosh: SFX_WHOOSH, star: SFX_STAR }; +const CAPS: Record = { chip: CAP_CHIP, sub: CAP_SUB, card: CAP_CARD }; +const DIRS: Record = { down: DIR_DOWN, up: DIR_UP, left: DIR_LEFT, right: DIR_RIGHT }; + +/** `base` = this cue's offset in the scene's concatenated cue blob. All jump + * targets are blob-absolute at runtime, so every emitted/patched target must + * add it (a sub-cue's `if` would otherwise jump into the play cue). */ +export function residualizeCue( + body: ts.FunctionExpression | ts.ArrowFunction, + ctx: CueCtx, + base = 0, +): Uint8Array { + const w = new ByteWriter(); + const endJumps: number[] = []; + const breakStack: number[][] = []; + + const err = (n: ts.Node, msg: string): never => { + const sf = n.getSourceFile(); + const { line } = sf.getLineAndCharacterOfPosition(n.getStart()); + throw new Error(`[${ctx.cueName}] ${msg} (line ${line + 1}): ${n.getText().slice(0, 80)}`); + }; + + const internVar = (name: string): number => { + let id = ctx.vars.get(name); + if (id === undefined) { + id = ctx.vars.size; + if (id >= N_VARS) throw new Error(`too many vars (max ${N_VARS}): ${name}`); + ctx.vars.set(name, id); + } + return id; + }; + const internFlag = (name: string): number => { + let id = ctx.flags.get(name); + if (id === undefined) { + id = ctx.flags.size; + if (id >= N_FLAGS) throw new Error(`too many flags (max ${N_FLAGS}): ${name}`); + ctx.flags.set(name, id); + } + return id; + }; + + const num = (n: ts.Expression): number => { + if (ts.isNumericLiteral(n)) return Number(n.text); + if (ts.isPrefixUnaryExpression(n) && n.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(n.operand)) + return -Number(n.operand.text); + return err(n, "expected a numeric literal"); + }; + const str = (n: ts.Expression): string => { + if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) return n.text; + return err(n, "expected a string literal"); + }; + const actor = (n: ts.Expression): { slot: number; proto: number; decl: ActorDecl } => { + const name = str(n); + const a = ctx.actors.get(name); + if (!a) return err(n, `unknown actor "${name}" in this scene`); + return a; + }; + + const asCall = (e: ts.Expression): { name: string; args: readonly ts.Expression[] } | null => { + if (ts.isCallExpression(e) && ts.isIdentifier(e.expression)) return { name: e.expression.text, args: e.arguments }; + return null; + }; + + /** value-producing yields usable in conditions / assignments (push result) */ + const emitValueYield = (call: { name: string; args: readonly ts.Expression[] }, n: ts.Node): void => { + switch (call.name) { + case "choice": { + const arr = call.args[0]; + if (!arr || !ts.isArrayLiteralExpression(arr)) return err(n, "choice() takes an array literal"); + const ids = arr.elements.map((e) => ctx.texts.intern(str(e as ts.Expression), { cols: 22, maxLines: 1 })); + if (ids.length < 2 || ids.length > MAX_CHOICES) return err(n, `choice() needs 2..${MAX_CHOICES} options`); + w.u8(OP.CHOICE).u8(ids.length); + for (const id of ids) w.u16(id); + return; + } + case "hasFlag": + w.u8(OP.GET_FLAG).u8(internFlag(str(call.args[0]))); + return; + case "rnd": + w.u8(OP.RND).u8(num(call.args[0])); + return; + case "varEq": + case "varNe": + case "varLt": + case "varGt": + case "varLe": + case "varGe": { + const kind = { varEq: CMP_EQ, varNe: CMP_NE, varLt: CMP_LT, varGt: CMP_GT, varLe: CMP_LE, varGe: CMP_GE }[ + call.name + ]!; + w.u8(OP.GET_VAR).u8(internVar(str(call.args[0]))); + w.u8(OP.PUSH).i16(num(call.args[1])); + w.u8(OP.CMP).u8(kind); + return; + } + case "world": + w.u8(OP.WORLD); + return; + case "action": + w.u8(OP.ACTION); + return; + case "breakout": { + const rows = num(call.args[0]); + const lives = num(call.args[1]); + const frames = call.args[2] ? num(call.args[2]) : 3600; + if (rows < 1 || rows > BRICK_ROWS_MAX) return err(n, `breakout rows 1..${BRICK_ROWS_MAX}`); + w.u8(OP.BREAKOUT).u8(rows).u8(lives).u16(frames); + return; + } + default: + return err(n, `${call.name}() does not produce a value here`); + } + }; + + /** condition -> leaves 0/1-ish on stack */ + const emitCond = (e: ts.Expression): void => { + if (ts.isParenthesizedExpression(e)) return emitCond(e.expression); + if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.ExclamationToken) { + emitCond(e.operand); + w.u8(OP.PUSH).i16(0); + w.u8(OP.CMP).u8(CMP_EQ); + return; + } + if (ts.isYieldExpression(e) && e.expression) { + const call = asCall(e.expression); + if (!call) return err(e, "yield in condition must be a call"); + return emitValueYield(call, e); + } + if (ts.isBinaryExpression(e)) { + const KINDS: Partial> = { + [ts.SyntaxKind.EqualsEqualsEqualsToken]: CMP_EQ, + [ts.SyntaxKind.EqualsEqualsToken]: CMP_EQ, + [ts.SyntaxKind.ExclamationEqualsEqualsToken]: CMP_NE, + [ts.SyntaxKind.ExclamationEqualsToken]: CMP_NE, + [ts.SyntaxKind.LessThanToken]: CMP_LT, + [ts.SyntaxKind.GreaterThanToken]: CMP_GT, + [ts.SyntaxKind.LessThanEqualsToken]: CMP_LE, + [ts.SyntaxKind.GreaterThanEqualsToken]: CMP_GE, + }; + const kind = KINDS[e.operatorToken.kind]; + if (kind === undefined) return err(e, "unsupported comparison"); + if (!ts.isIdentifier(e.left)) return err(e, "left side must be a result variable"); + w.u8(OP.GET_VAR).u8(internVar(localName(e.left.text))); + w.u8(OP.PUSH).i16(num(e.right)); + w.u8(OP.CMP).u8(kind); + return; + } + if (ts.isIdentifier(e)) { + w.u8(OP.GET_VAR).u8(internVar(localName(e.text))); + return; + } + return err(e, "unsupported condition"); + }; + + const localName = (name: string): string => `${ctx.cueName}:${name}`; + + /** statement-position yield op table */ + const emitOp = (call: { name: string; args: readonly ts.Expression[] }, n: ts.Node): void => { + const a = call.args; + const easeArg = (i: number, dflt: number): number => { + if (!a[i]) return dflt; + const e = EASES[str(a[i])]; + if (e === undefined) return err(n, `unknown ease ${a[i].getText()}`); + return e; + }; + switch (call.name) { + case "fadeIn": + case "fadeOut": { + const frames = a[0] ? num(a[0]) : 30; + const white = a[1] ? str(a[1]) === "white" : false; + const mode = call.name === "fadeIn" ? (white ? FADE_IN_WHITE : FADE_IN_BLACK) : white ? FADE_OUT_WHITE : FADE_OUT_BLACK; + w.u8(OP.FADE).u8(mode).u16(frames); + return; + } + case "wait": + w.u8(OP.WAIT).u16(num(a[0])); + return; + case "waitA": + w.u8(OP.WAITA); + return; + case "waitTweens": + w.u8(OP.WAIT_TWEENS); + return; + case "caption": { + const style = CAPS[str(a[0])]; + if (style === undefined) return err(n, "caption style must be chip|sub|card"); + const opts = style === CAP_CHIP ? { cols: 24, maxLines: 1 } : {}; + w.u8(OP.CAPTION).u8(style).u16(ctx.texts.intern(str(a[1]), opts)); + return; + } + case "captionClear": { + const s = a[0] ? str(a[0]) : "all"; + w.u8(OP.CAPTION_CLR).u8(s === "all" ? 0xff : (CAPS[s] ?? 0xff)); + return; + } + case "dialog": + w.u8(OP.DIALOG) + .u16(ctx.texts.intern(str(a[0]), { cols: 12, maxLines: 1 })) + .u16(ctx.texts.intern(str(a[1]))); + return; + case "pan": + w.u8(OP.TWEEN).u8(TW.CAM_X).u8(easeArg(2, EASE_INOUT)).i16(num(a[0])).u16(num(a[1])); + return; + case "panY": + w.u8(OP.TWEEN).u8(TW.CAM_Y).u8(easeArg(2, EASE_INOUT)).i16(num(a[0])).u16(num(a[1])); + return; + case "alpha": + w.u8(OP.TWEEN).u8(TW.EVA).u8(EASE_LINEAR).i16(num(a[0])).u16(num(a[2])); + w.u8(OP.TWEEN).u8(TW.EVB).u8(EASE_LINEAR).i16(num(a[1])).u16(num(a[2])); + return; + case "mosaicTo": + w.u8(OP.TWEEN).u8(TW.MOSAIC).u8(EASE_LINEAR).i16(num(a[0])).u16(num(a[1])); + return; + case "shake": + w.u8(OP.TWEEN).u8(TW.SHAKE).u8(EASE_LINEAR).i16(num(a[0])).u16(0); + w.u8(OP.TWEEN).u8(TW.SHAKE).u8(EASE_OUT).i16(0).u16(num(a[1])); + return; + case "autoScroll": { + const t = str(a[0]) === "far" ? TW.FAR_VX : TW.SKY_VX; + w.u8(OP.TWEEN).u8(t).u8(EASE_LINEAR).i16(Math.round(numF(a[1]) * 256)).u16(a[2] ? num(a[2]) : 0); + return; + } + case "zoom": + w.u8(OP.TWEEN).u8(TW.OBJ_SCALE).u8(easeArg(2, EASE_INOUT)).i16(Math.round(numF(a[0]) * 256)).u16(num(a[1])); + return; + case "spinTo": + w.u8(OP.TWEEN).u8(TW.OBJ_ANGLE).u8(easeArg(2, EASE_LINEAR)).i16(Math.round((num(a[0]) / 360) * 256)).u16(num(a[1])); + return; + case "letterbox": + w.u8(OP.LETTERBOX).u8(num(a[0])).u16(a[1] ? num(a[1]) : 20); + return; + case "rasterWave": + w.u8(OP.RASTER).u8(str(a[0]) === "far" ? RASTER_WAVE_FAR : RASTER_WAVE_MAIN).u8(num(a[1])); + return; + case "rasterGradient": + w.u8(OP.RASTER).u8(RASTER_GRADIENT).u8(0); + return; + case "rasterOff": + w.u8(OP.RASTER).u8(RASTER_OFF).u8(0); + return; + case "show": { + const ac = actor(a[0]); + const x = a[1] ? num(a[1]) : (ac.decl.at?.[0] ?? 0); + const y = a[2] ? num(a[2]) : (ac.decl.at?.[1] ?? 0); + let flags = 0; + if (ac.decl.flip) flags |= SPR_HFLIP; + if (ac.decl.screen) flags |= SPR_SCREEN; + if (ac.decl.behind) flags |= SPR_BEHIND; + if (ac.decl.ghost) flags |= SPR_GHOST; + if (a[3] && ts.isObjectLiteralExpression(a[3])) { + for (const p of a[3].properties) { + if (ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "flip") { + if (p.initializer.kind === ts.SyntaxKind.TrueKeyword) flags |= SPR_HFLIP; + else flags &= ~SPR_HFLIP; + } + } + } + w.u8(OP.SPRITE_SHOW).u8(ac.slot).u8(ac.proto).i16(x).i16(y).u8(flags); + return; + } + case "hide": + w.u8(OP.SPRITE_HIDE).u8(actor(a[0]).slot); + return; + case "animate": { + const ac = actor(a[0]); + const mode = ts.isStringLiteral(a[1]) ? 1 : 0; + const arg = mode === 1 ? (a[2] ? num(a[2]) : 0) : num(a[1]); + w.u8(OP.SPRITE_ANIM).u8(ac.slot).u8(mode).u8(arg); + return; + } + case "moveTo": { + const ac = actor(a[0]); + w.u8(OP.SPRITE_MOVE).u8(ac.slot).u8(easeArg(3 + 1, EASE_INOUT)).i16(num(a[1])).i16(num(a[2])).u16(num(a[3])); + return; + } + case "walkTo": { + const ac = actor(a[0]); + w.u8(OP.SPRITE_ANIM).u8(ac.slot).u8(1).u8(0); + w.u8(OP.TWEEN).u8(TW_SPRITE_BASE + ac.slot * 2).u8(EASE_LINEAR).i16(num(a[1])).u16(num(a[2])); + w.u8(OP.WAIT_TWEENS); + w.u8(OP.SPRITE_ANIM).u8(ac.slot).u8(0).u8(0); + return; + } + case "control": { + const ac = actor(a[0]); + const speed = a[2] ? numF(a[2]) : 1.5; + w.u8(OP.CONTROL).u8(ac.slot).i16(num(a[1])).u8(Math.max(1, Math.round(speed * 16))); + return; + } + case "mash": + w.u8(OP.MASH).u8(internVar(str(a[0]))).u16(num(a[1])); + return; + case "counter": + w.u8(OP.COUNTER).u8(internVar(str(a[0]))).u8(1).i16(num(a[1])).i16(num(a[2])); + return; + case "counterHide": + w.u8(OP.COUNTER).u8(0).u8(0).i16(0).i16(0); + return; + case "affineOn": + w.u8(OP.AFFINE).u8(actor(a[0]).slot).u8(1); + return; + case "affineOff": + w.u8(OP.AFFINE).u8(actor(a[0]).slot).u8(0); + return; + case "sfx": { + const id = SFXS[str(a[0])]; + if (id === undefined) return err(n, "unknown sfx"); + w.u8(OP.SFX).u8(id); + return; + } + case "gotoScene": { + const idx = ctx.sceneIndex.get(str(a[0])); + if (idx === undefined) return err(n, `unknown scene "${str(a[0])}"`); + w.u8(OP.GOTO_SCENE).u8(idx); + return; + } + case "world": + case "breakout": + case "action": + // statement position: run it, discard the pushed result + emitValueYield(call, n); + w.u8(OP.POP); + return; + case "music": { + const name = str(a[0]); + if (name === "off") { + w.u8(OP.MUSIC).u8(0xff); + return; + } + const id = ctx.trackIndex.get(name); + if (id === undefined) return err(n, `unknown music track "${name}" (declare it in the film's music table)`); + w.u8(OP.MUSIC).u8(id); + return; + } + case "meterShow": + w.u8(OP.METER) + .u8(num(a[0])) + .u8(internVar(str(a[1]))) + .i16(num(a[2])) + .i16(num(a[3])) + .u8(num(a[4])) + .u8(1); + return; + case "meterHide": + w.u8(OP.METER).u8(num(a[0])).u8(0).i16(0).i16(0).u8(1).u8(0); + return; + case "warp": { + const dir = a[2] ? DIRS[str(a[2])] : DIR_DOWN; + if (dir === undefined) return err(n, "warp dir must be down|up|left|right"); + w.u8(OP.WARP).u8(num(a[0])).u8(num(a[1])).u8(dir); + return; + } + case "face": { + const dir = DIRS[str(a[1])]; + if (dir === undefined) return err(n, "face dir must be down|up|left|right"); + w.u8(OP.FACE).u8(actor(a[0]).slot).u8(dir); + return; + } + case "walk": + w.u8(OP.WALK).u8(actor(a[0]).slot).u8(num(a[1])).u8(num(a[2])); + return; + case "setFlag": + w.u8(OP.SET_FLAG).u8(internFlag(str(a[0]))); + return; + case "clrFlag": + w.u8(OP.CLR_FLAG).u8(internFlag(str(a[0]))); + return; + case "setVar": + if (ts.isIdentifier(a[1])) w.u8(OP.GET_VAR).u8(internVar(localName(a[1].text))); + else w.u8(OP.PUSH).i16(num(a[1])); + w.u8(OP.SET_VAR).u8(internVar(str(a[0]))); + return; + case "addVar": + w.u8(OP.ADD_VAR).u8(internVar(str(a[0]))).i16(num(a[1])); + return; + default: + return err(n, `unknown cue op ${call.name}()`); + } + }; + + const numF = (e: ts.Expression): number => { + if (ts.isNumericLiteral(e)) return Number(e.text); + if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(e.operand)) + return -Number(e.operand.text); + return err(e, "expected a number"); + }; + + const emitStmt = (s: ts.Statement): void => { + if (ts.isExpressionStatement(s)) { + const e = s.expression; + if (ts.isYieldExpression(e) && e.expression) { + const call = asCall(e.expression); + if (!call) return void err(s, "yield must wrap a cue op call"); + emitOp(call, s); + return; + } + return void err(s, "only `yield op(...)` expression statements are allowed"); + } + if (ts.isVariableStatement(s)) { + for (const d of s.declarationList.declarations) { + if (!d.initializer || !ts.isYieldExpression(d.initializer) || !d.initializer.expression) + return void err(s, "const x = yield (...) is the only declaration form"); + const call = asCall(d.initializer.expression); + if (!call) return void err(s, "expected a value-producing cue op"); + emitValueYield(call, s); + if (!ts.isIdentifier(d.name)) return void err(s, "destructuring not supported"); + w.u8(OP.SET_VAR).u8(internVar(localName(d.name.text))); + } + return; + } + if (ts.isIfStatement(s)) { + emitCond(s.expression); + w.u8(OP.JZ); + const jzAt = w.length; + w.u16(0); + emitBlock(s.thenStatement); + if (s.elseStatement) { + w.u8(OP.JMP); + const jmpAt = w.length; + w.u16(0); + w.patchU16(jzAt, base + w.length); + emitBlock(s.elseStatement); + w.patchU16(jmpAt, base + w.length); + } else { + w.patchU16(jzAt, base + w.length); + } + return; + } + if (ts.isWhileStatement(s)) { + const loopStart = w.length; + emitCond(s.expression); + w.u8(OP.JZ); + const jzAt = w.length; + w.u16(0); + breakStack.push([]); + emitBlock(s.statement); + w.u8(OP.JMP).u16(base + loopStart); + w.patchU16(jzAt, base + w.length); + for (const b of breakStack.pop()!) w.patchU16(b, base + w.length); + return; + } + if (ts.isBreakStatement(s)) { + if (!breakStack.length) return void err(s, "break outside while"); + w.u8(OP.JMP); + breakStack[breakStack.length - 1].push(w.length); + w.u16(0); + return; + } + if (ts.isReturnStatement(s)) { + w.u8(OP.JMP); + endJumps.push(w.length); + w.u16(0); + return; + } + if (ts.isBlock(s)) { + for (const st of s.statements) emitStmt(st); + return; + } + if (ts.isEmptyStatement(s)) return; + return void err(s, `unsupported statement kind ${ts.SyntaxKind[s.kind]}`); + }; + + const emitBlock = (s: ts.Statement): void => { + if (ts.isBlock(s)) for (const st of s.statements) emitStmt(st); + else emitStmt(s); + }; + + if (!body.body || !ts.isBlock(body.body)) throw new Error(`[${ctx.cueName}] cue body must be a block`); + for (const st of body.body.statements) emitStmt(st); + + for (const j of endJumps) w.patchU16(j, base + w.length); + w.u8(OP.END); + return w.toUint8Array(); +} diff --git a/edge/compiler/rom.ts b/edge/compiler/rom.ts new file mode 100644 index 0000000..d07866d --- /dev/null +++ b/edge/compiler/rom.ts @@ -0,0 +1,77 @@ +// edge/compiler/rom.ts — link the runtime + gen_data.c into a real .gba ROM +// and patch the header (BIOS logo bitmap + title + complement checksum) so it +// boots on hardware, not just emulators. + +import { $ } from "bun"; + +const RT = new URL("../runtime", import.meta.url).pathname; +const DIST = new URL("../dist", import.meta.url).pathname; + +// prettier-ignore +const GBA_LOGO = [ + 0x24, 0xff, 0xae, 0x51, 0x69, 0x9a, 0xa2, 0x21, 0x3d, 0x84, 0x82, 0x0a, 0x84, 0xe4, 0x09, 0xad, + 0x11, 0x24, 0x8b, 0x98, 0xc0, 0x81, 0x7f, 0x21, 0xa3, 0x52, 0xbe, 0x19, 0x93, 0x09, 0xce, 0x20, + 0x10, 0x46, 0x4a, 0x4a, 0xf8, 0x27, 0x31, 0xec, 0x58, 0xc7, 0xe8, 0x33, 0x82, 0xe3, 0xce, 0xbf, + 0x85, 0xf4, 0xdf, 0x94, 0xce, 0x4b, 0x09, 0xc1, 0x94, 0x56, 0x8a, 0xc0, 0x13, 0x72, 0xa7, 0xfc, + 0x9f, 0x84, 0x4d, 0x73, 0xa3, 0xca, 0x9a, 0x61, 0x58, 0x97, 0xa3, 0x27, 0xfc, 0x03, 0x98, 0x76, + 0x23, 0x1d, 0xc7, 0x61, 0x03, 0x04, 0xae, 0x56, 0xbf, 0x38, 0x84, 0x00, 0x40, 0xa7, 0x0e, 0xfd, + 0xff, 0x52, 0xfe, 0x03, 0x6f, 0x95, 0x30, 0xf1, 0x97, 0xfb, 0xc0, 0x85, 0x60, 0xd6, 0x80, 0x25, + 0xa9, 0x63, 0xbe, 0x03, 0x01, 0x4e, 0x38, 0xe2, 0xf9, 0xa2, 0x34, 0xff, 0xbb, 0x3e, 0x03, 0x44, + 0x78, 0x00, 0x90, 0xcb, 0x88, 0x11, 0x3a, 0x94, 0x65, 0xc0, 0x7c, 0x63, 0x87, 0xf0, 0x3c, 0xaf, + 0xd6, 0x25, 0xe4, 0x8b, 0x38, 0x0a, 0xac, 0x72, 0x21, 0xd4, 0xf8, 0x07, +]; + +function patchHeader(rom: Uint8Array, title: string): void { + if (rom.length < 0xc0) throw new Error("ROM too small for a GBA header"); + rom.set(GBA_LOGO, 0x04); + const t = title.replace(/[^\x20-\x7e]/g, "").toUpperCase().slice(0, 12).padEnd(12, "\0"); + for (let i = 0; i < 12; i++) rom[0xa0 + i] = t.charCodeAt(i); + rom[0xb2] = 0x96; + let sum = 0; + for (let a = 0xa0; a <= 0xbc; a++) sum += rom[a]; + rom[0xbd] = (-(0x19 + sum)) & 0xff; +} + +export interface BuildRomResult { + gba: string; + elf: string; + size: number; +} + +export async function buildRom( + genDataC: string, + outPath: string, + title = "EDGE", + genMusicS = "", +): Promise { + await Bun.write(RT + "/gen_data.c", genDataC); + await Bun.write(RT + "/gen_music.s", genMusicS || "/* gen_music.s — no tracks */\n"); + await $`mkdir -p ${DIST}`.quiet(); + + const elf = DIST + "/film.elf"; + const CFLAGS = [ + "-mcpu=arm7tdmi", + "-marm", + "-ffreestanding", + "-nostdlib", + "-O2", + "-fno-strict-aliasing", + "-Wall", + ]; + const sources = [ + `${RT}/crt0.s`, + `${RT}/gen_music.s`, + ...[ + "main", "video", "irq", "input", "tween", "fx", "obj", "caption", "cue_vm", + "world", "breakout", "action", "audio", "sfx", "debug", "gen_data", + ].map((m) => `${RT}/${m}.c`), + ]; + + await $`arm-none-eabi-gcc ${CFLAGS} -I${RT} -T${RT}/gba.ld ${sources} -lgcc -o ${elf}`.quiet(); + await $`arm-none-eabi-objcopy -O binary ${elf} ${outPath}`.quiet(); + + const rom = new Uint8Array(await Bun.file(outPath).arrayBuffer()); + patchHeader(rom, title); + await Bun.write(outPath, rom); + return { gba: outPath, elf, size: rom.length }; +} diff --git a/edge/compiler/text.ts b/edge/compiler/text.ts new file mode 100644 index 0000000..e59e3ba --- /dev/null +++ b/edge/compiler/text.ts @@ -0,0 +1,146 @@ +// edge/compiler/text.ts — caption text bank: wrap, tokenize, glyph interning. +// Captions are at most C_CAP_LINES x CAP_COLS cells; the compiler wraps and +// validates at build time so the runtime never measures anything. + +import { CAP_COLS, CAP_LINES, TOK_END, TOK_NL, ByteWriter } from "../spec/edge.ts"; +import { unifontGlyph, halfcellPixels } from "./cjk.ts"; + +// Anything outside printable ASCII goes through the fullwidth-glyph path and +// occupies 2 cells (halfwidth Unifont glyphs get a blank right half). +const isAscii = (ch: string): boolean => { + const cp = ch.codePointAt(0)!; + return cp >= 0x20 && cp <= 0x7e; +}; +const cellW = (ch: string): number => (isAscii(ch) ? 1 : 2); + +/** Wrap into <= maxLines lines of <= cols cells. Manual "\n" respected. */ +export function wrapText(text: string, cols = CAP_COLS, maxLines = CAP_LINES): string[] { + const out: string[] = []; + for (const para of text.split("\n")) { + let line = ""; + let w = 0; + // units: ASCII runs stay whole; CJK breaks anywhere + const units = para.match(/[\x21-\x7e]+| +|[^\x20-\x7e]/g) ?? []; + for (const u of units) { + const uw = [...u].reduce((n, c) => n + cellW(c), 0); + if (w + uw > cols && w > 0) { + out.push(line); + line = u === " " || /^ +$/.test(u) ? "" : u; + w = line ? uw : 0; + } else { + line += u; + w += uw; + } + } + out.push(line); + } + while (out.length && out[out.length - 1] === "") out.pop(); + if (out.length > maxLines) { + throw new Error(`caption overflows ${maxLines} lines: ${JSON.stringify(text)} -> ${JSON.stringify(out)}`); + } + return out; +} + +export class TextBank { + private ids = new Map(); + readonly entries: { raw: string; lines: string[] }[] = []; + /** fullwidth glyph interner: char -> glyph id */ + private glyphIds = new Map(); + readonly glyphChars: string[] = []; + + intern(text: string, opts: { cols?: number; maxLines?: number } = {}): number { + const key = text; + const hit = this.ids.get(key); + if (hit !== undefined) return hit; + const lines = wrapText(text, opts.cols ?? CAP_COLS, opts.maxLines ?? CAP_LINES); + const id = this.entries.length; + this.entries.push({ raw: text, lines }); + this.ids.set(key, id); + for (const line of lines) { + for (const ch of line) { + if (isAscii(ch)) continue; + if (!this.glyphIds.has(ch)) { + this.glyphIds.set(ch, this.glyphChars.length); + this.glyphChars.push(ch); + } + } + } + return id; + } + + tokenize(id: number): Uint8Array { + const w = new ByteWriter(); + const { lines } = this.entries[id]; + lines.forEach((line, i) => { + if (i > 0) w.u8(TOK_NL); + for (const ch of line) { + const cp = ch.codePointAt(0)!; + if (isAscii(ch)) { + w.u8(cp); + } else { + const gid = this.glyphIds.get(ch); + if (gid === undefined) throw new Error(`glyph not interned: ${ch}`); + w.u8(0x80 | ((gid >> 8) & 0x7f)).u8(gid & 0xff); + } + } + }); + w.u8(TOK_END); + return w.toUint8Array(); + } + + /** Glyph store: 95 ASCII halfcells + 2 halfcells per fullwidth glyph. + * Each halfcell = two stacked 4bpp 8x8 tiles (64 bytes), ink/bg indices. */ + bakeGlyphStore(ink: number, bg: number): Uint8Array { + const halfcells: Uint8Array[] = []; + const pack = (px: number[]): Uint8Array => { + const t = new Uint8Array(32); + for (let row = 0; row < 8; row++) + for (let c = 0; c < 4; c++) { + const lo = px[row * 8 + c * 2] & 0xf; + const hi = px[row * 8 + c * 2 + 1] & 0xf; + t[row * 4 + c] = lo | (hi << 4); + } + return t; + }; + const bake = (cp: number | null, half: 0 | 1): Uint8Array => { + const glyph = cp === null ? null : unifontGlyph(cp); + const [top, bottom] = halfcellPixels(glyph, half, ink, bg); + const out = new Uint8Array(64); + out.set(pack(top), 0); + out.set(pack(bottom), 32); + return out; + }; + for (let cp = 0x20; cp <= 0x7e; cp++) halfcells.push(bake(cp, 0)); + for (const ch of this.glyphChars) { + const cp = ch.codePointAt(0)!; + const g = unifontGlyph(cp); + if (g && g.width === 8) { + halfcells.push(bake(cp, 0), bake(null, 0)); // halfwidth glyph + blank right half + } else { + halfcells.push(bake(cp, 0), bake(cp, 1)); + } + } + const blob = new Uint8Array(halfcells.length * 64); + halfcells.forEach((hc, i) => blob.set(hc, i * 64)); + return blob; + } + + buildBlob(): { offs: number[]; blob: Uint8Array } { + const offs: number[] = []; + const parts: Uint8Array[] = []; + let cur = 0; + for (let i = 0; i < this.entries.length; i++) { + const t = this.tokenize(i); + offs.push(cur); + parts.push(t); + cur += t.length; + } + const blob = new Uint8Array(cur); + let o = 0; + for (const p of parts) { + blob.set(p, o); + o += p.length; + } + return { offs, blob }; + } +} diff --git a/edge/dsl/index.ts b/edge/dsl/index.ts new file mode 100644 index 0000000..0d63cdb --- /dev/null +++ b/edge/dsl/index.ts @@ -0,0 +1,272 @@ +// edge/dsl/index.ts — the @pocketjs/edge authoring surface. +// +// Two zones, same discipline as @pocketjs/aot: +// - the DECLARATION zone (defineFilm/defineScene/image/gradient/sprite) is +// executed at build time and fills a registry; +// - the RESIDUAL zone (`play: cue(function* () { ... })`) is never executed — +// the compiler lowers the generator's AST to cue bytecode. The op functions +// exported here are compile-time vocabulary; calling them outside a cue() +// body is an authoring error. + +export type Ease = "linear" | "in" | "out" | "inout"; +export type CaptionStyle = "chip" | "sub" | "card"; +export type Dir = "down" | "up" | "left" | "right"; + +export interface GradientDecl { + kind: "gradient"; + stops: string[]; +} +export interface LayerDecl { + kind: "image"; + png: string; + scroll?: number; // parallax factor vs camera (default: far .4, sky .15) + vx?: number; // autoscroll px/frame (fractions fine) + wide?: boolean; // main only: image wider than 240 -> 64x32 map + y?: number; // vertical placement offset in px (multiple of 8) +} +export interface ActorDecl { + png: string; + w: number; + h: number; + frames?: number; // horizontal strip + fps?: number; // anim frame period (frames per cell) + at?: [number, number]; + show?: boolean; + flip?: boolean; + ghost?: boolean; // OBJ semi-transparency + behind?: boolean; // prio 3, behind the main stage + screen?: boolean; // screen-space (HUD-like) + /** walker sheet: frames-per-direction. The strip holds 3*walkFpd frames in + * row order DOWN, UP, SIDE (right = SIDE hflipped); frame 0 of a row stands. */ + walkFpd?: number; +} + +// --- world (top-down grid) declarations ---------------------------------------- +// `at` accepts a letter naming cells in the grid, or explicit cells. +export type CellRef = string | [number, number]; +export type RectRef = string | [number, number, number, number]; // cx,cy,w,h + +export interface WorldPlayerDecl { + actor: string; + at: CellRef; + dir?: Dir; +} +export interface NpcDecl { + actor: string; + at: CellRef; + dir?: Dir; + talk?: CueRef; // run on A-interact + solid?: boolean; // default true +} +export interface ExitDecl { + at: RectRef; + value: number; // pushed as the result of `yield world()` +} +export interface SpotDecl { + at: RectRef; + run: CueRef; +} +export interface WorldDecl { + /** one string per cell row: '#' solid, '.'/' ' walkable, letters name cells + * (walkable). Must match the main image: ceil(w/16) x ceil(h/16). */ + grid: string[]; + player: WorldPlayerDecl; + npcs?: Record; + exits?: Record; // step onto -> world() returns value + spots?: Record; // face + A -> run cue (examine) + autos?: Record; // step onto (once) -> run cue +} + +// --- action (side-scrolling run-and-gun) declarations --------------------------- +export type EnemyType = "thug" | "gunner" | "drone" | "turret" | "boss"; + +export interface ActionSpawnDecl { + type: EnemyType; + actor: string; // 4-frame side strip (idle, walk0, walk1, attack); boss may be 64px + x: number; // world px (activates as the player approaches) + hp?: number; + wave?: number; // gate tag (see gates) +} + +export interface ActionDecl { + /** actor with the 8-frame side-view strip: idle, run x4, jump, shoot, hurt. + * Must be the FIRST actor declared in the scene (it lives in sprite slot 0). */ + player: { actor: string; hp?: number; sande?: number }; + ground: number; // ground surface y, world px + length?: number; // virtual stage px (default: main image width; map wraps at 512) + platforms?: [number, number, number][]; // x, y(top surface), w — one-way + gates?: { x: number; wave: number }[]; // hold the player at x while wave lives + spawns: ActionSpawnDecl[]; + exit?: "end" | "clear"; // reach length (default) | kill every spawn + /** boss stages: when the boss' hp falls to this, the stage ends immediately + * with result 2 (ACT_BOSS_PHASE) and the story takes over. */ + bossPhaseHp?: number; + /** game vars bound to the HUD/state (share the film-wide var pool) */ + vars?: { hp?: string; sande?: string; kills?: string; deaths?: string }; +} + +export interface TrackDecl { + kind: "track"; + raw: string; // s8 mono PCM @ 13379 Hz (ffmpeg -ac 1 -ar 13379 -f s8) + loop?: boolean; +} + +export interface SceneDecl { + id: string; + sky?: GradientDecl | LayerDecl; + far?: LayerDecl; + main?: LayerDecl; + backdrop?: string; // hex color when no gradient (default #000000) + camera?: { start?: number; min?: number; max?: number }; + letterbox?: number; // initial bar height px + wave?: { layer: "main" | "far"; amp: number }; // raster sine on from scene start + actors?: Record; + /** presence makes this a WORLD scene: BG1 becomes a 64x64 walkable map + * (no far/sky layers), and `yield world()` hands input to the player. */ + world?: WorldDecl; + /** presence makes this an ACTION scene: `yield action()` runs the stage. */ + action?: ActionDecl; + play: CueRef; +} + +export interface FilmDecl { + title: string; + scenes: SceneDecl[]; + /** PCM insert songs by name: `music: { stay: track("music/stay.raw", { loop: true }) }` */ + music?: Record; +} + +export interface CueRef { + __cue: number; +} + +export interface Registry { + film: FilmDecl | null; + scenes: SceneDecl[]; +} + +const REGISTRY: Registry = { film: null, scenes: [] }; + +export function __getRegistry(): Registry { + return REGISTRY; +} +export function __resetRegistry(): void { + REGISTRY.film = null; + REGISTRY.scenes = []; +} + +// --- declaration zone --------------------------------------------------------- + +export function defineScene(decl: SceneDecl): SceneDecl { + REGISTRY.scenes.push(decl); + return decl; +} + +export function defineFilm(decl: FilmDecl): FilmDecl { + if (REGISTRY.film) throw new Error("defineFilm() called twice"); + REGISTRY.film = decl; + return decl; +} + +export function image(png: string, opts: Omit = {}): LayerDecl { + return { kind: "image", png, ...opts }; +} + +export function gradient(...stops: string[]): GradientDecl { + if (stops.length < 2) throw new Error("gradient() needs at least 2 stops"); + return { kind: "gradient", stops }; +} + +export function track(raw: string, opts: Omit = {}): TrackDecl { + return { kind: "track", raw, ...opts }; +} + +export function sprite(png: string, opts: Omit): ActorDecl { + return { png, ...opts }; +} + +/** Residual generator marker. The compiler replaces the argument with an id. */ +export function cue(fn: number | (() => Generator)): CueRef { + if (typeof fn === "number") return { __cue: fn }; + throw new Error("cue() bodies are residual-only; compile with edge/compiler"); +} + +// --- residual zone vocabulary --------------------------------------------------- +// Every function below may ONLY appear inside cue(function* () { ... }) as +// `yield op(...)` (or in if/while conditions where noted). Bodies never run. + +const residual = (name: string) => (): never => { + throw new Error(`${name}() is residual-only — use it inside cue(function* () {...})`); +}; + +type R = unknown; + +// blocking +export const fadeIn = residual("fadeIn") as (frames?: number, color?: "black" | "white") => R; +export const fadeOut = residual("fadeOut") as (frames?: number, color?: "black" | "white") => R; +export const wait = residual("wait") as (frames: number) => R; +export const waitA = residual("waitA") as () => R; +export const waitTweens = residual("waitTweens") as () => R; +export const caption = residual("caption") as (style: CaptionStyle, text: string) => R; +export const dialog = residual("dialog") as (speaker: string, text: string) => R; +export const choice = residual("choice") as (options: string[]) => number; +export const walkTo = residual("walkTo") as (actor: string, x: number, frames: number) => R; +export const control = residual("control") as (actor: string, exitX: number, speed?: number) => R; +export const mash = residual("mash") as (varName: string, target: number) => R; + +// non-blocking +export const captionClear = residual("captionClear") as (style?: CaptionStyle | "all") => R; +export const pan = residual("pan") as (x: number, frames: number, ease?: Ease) => R; +export const panY = residual("panY") as (y: number, frames: number, ease?: Ease) => R; +export const alpha = residual("alpha") as (eva: number, evb: number, frames: number) => R; +export const mosaicTo = residual("mosaicTo") as (level: number, frames: number) => R; +export const shake = residual("shake") as (amp: number, frames: number) => R; +export const autoScroll = residual("autoScroll") as (layer: "far" | "sky", vx: number, frames?: number) => R; +export const zoom = residual("zoom") as (scale: number, frames: number, ease?: Ease) => R; +export const spinTo = residual("spinTo") as (angle: number, frames: number, ease?: Ease) => R; +export const letterbox = residual("letterbox") as (px: number, frames?: number) => R; +export const rasterWave = residual("rasterWave") as (layer: "main" | "far", amp: number) => R; +export const rasterGradient = residual("rasterGradient") as () => R; +export const rasterOff = residual("rasterOff") as () => R; +export const show = residual("show") as ( + actor: string, + x?: number, + y?: number, + opts?: { flip?: boolean }, +) => R; +export const hide = residual("hide") as (actor: string) => R; +export const animate = residual("animate") as (actor: string, mode: "loop" | number, fps?: number) => R; +export const moveTo = residual("moveTo") as (actor: string, x: number, y: number, frames: number, ease?: Ease) => R; +export const affineOn = residual("affineOn") as (actor: string) => R; +export const affineOff = residual("affineOff") as (actor: string) => R; +export const counter = residual("counter") as (varName: string, x: number, y: number) => R; +export const counterHide = residual("counterHide") as () => R; +export const sfx = residual("sfx") as (id: "blip" | "confirm" | "whoosh" | "star") => R; +export const gotoScene = residual("gotoScene") as (sceneId: string) => R; + +// world + encounters + minigames + action + music +export const world = residual("world") as () => number; // blocks; returns exit value +export const breakout = residual("breakout") as (rows: number, lives: number, frames?: number) => number; +/** blocks on the scene's action stage; returns 1 (cleared) or 2 (boss phase) */ +export const action = residual("action") as () => number; +/** start/stop a PCM insert song declared in the film's `music` table */ +export const music = residual("music") as (name: string | "off") => R; +export const meterShow = residual("meterShow") as (id: 0 | 1, varName: string, x: number, y: number, max: number) => R; +export const meterHide = residual("meterHide") as (id: 0 | 1) => R; +export const warp = residual("warp") as (cx: number, cy: number, dir?: Dir) => R; +export const face = residual("face") as (actor: string, dir: Dir) => R; +export const walk = residual("walk") as (actor: string, cx: number, cy: number) => R; + +// state (usable in conditions) +export const setFlag = residual("setFlag") as (name: string) => R; +export const clrFlag = residual("clrFlag") as (name: string) => R; +export const hasFlag = residual("hasFlag") as (name: string) => number; +export const setVar = residual("setVar") as (name: string, v: number) => R; +export const addVar = residual("addVar") as (name: string, d: number) => R; +export const varEq = residual("varEq") as (name: string, v: number) => number; +export const varNe = residual("varNe") as (name: string, v: number) => number; +export const varLt = residual("varLt") as (name: string, v: number) => number; +export const varGt = residual("varGt") as (name: string, v: number) => number; +export const varLe = residual("varLe") as (name: string, v: number) => number; +export const varGe = residual("varGe") as (name: string, v: number) => number; +export const rnd = residual("rnd") as (n: number) => number; diff --git a/edge/game/art/act_david.png b/edge/game/art/act_david.png new file mode 100644 index 0000000..a89342a Binary files /dev/null and b/edge/game/art/act_david.png differ diff --git a/edge/game/art/act_david_hurt.png b/edge/game/art/act_david_hurt.png new file mode 100644 index 0000000..d9d745c Binary files /dev/null and b/edge/game/art/act_david_hurt.png differ diff --git a/edge/game/art/act_david_idle.png b/edge/game/art/act_david_idle.png new file mode 100644 index 0000000..70956a4 Binary files /dev/null and b/edge/game/art/act_david_idle.png differ diff --git a/edge/game/art/act_david_jump.png b/edge/game/art/act_david_jump.png new file mode 100644 index 0000000..82abe35 Binary files /dev/null and b/edge/game/art/act_david_jump.png differ diff --git a/edge/game/art/act_david_shoot.png b/edge/game/art/act_david_shoot.png new file mode 100644 index 0000000..6555bf0 Binary files /dev/null and b/edge/game/art/act_david_shoot.png differ diff --git a/edge/game/art/bg_apartment.png b/edge/game/art/bg_apartment.png new file mode 100644 index 0000000..38591ea Binary files /dev/null and b/edge/game/art/bg_apartment.png differ diff --git a/edge/game/art/bg_clinic.png b/edge/game/art/bg_clinic.png new file mode 100644 index 0000000..81abff1 Binary files /dev/null and b/edge/game/art/bg_clinic.png differ diff --git a/edge/game/art/bg_moon.png b/edge/game/art/bg_moon.png new file mode 100644 index 0000000..5d6546a Binary files /dev/null and b/edge/game/art/bg_moon.png differ diff --git a/edge/game/art/bg_rooftop.png b/edge/game/art/bg_rooftop.png new file mode 100644 index 0000000..7d09d70 Binary files /dev/null and b/edge/game/art/bg_rooftop.png differ diff --git a/edge/game/art/boss_smasher.png b/edge/game/art/boss_smasher.png new file mode 100644 index 0000000..d0ce9c7 Binary files /dev/null and b/edge/game/art/boss_smasher.png differ diff --git a/edge/game/art/boss_smasher_attack.png b/edge/game/art/boss_smasher_attack.png new file mode 100644 index 0000000..06f0995 Binary files /dev/null and b/edge/game/art/boss_smasher_attack.png differ diff --git a/edge/game/art/boss_smasher_idle.png b/edge/game/art/boss_smasher_idle.png new file mode 100644 index 0000000..3c46c74 Binary files /dev/null and b/edge/game/art/boss_smasher_idle.png differ diff --git a/edge/game/art/en_cop.png b/edge/game/art/en_cop.png new file mode 100644 index 0000000..25b3bc2 Binary files /dev/null and b/edge/game/art/en_cop.png differ diff --git a/edge/game/art/en_cop_attack.png b/edge/game/art/en_cop_attack.png new file mode 100644 index 0000000..b24bd2a Binary files /dev/null and b/edge/game/art/en_cop_attack.png differ diff --git a/edge/game/art/en_cop_idle.png b/edge/game/art/en_cop_idle.png new file mode 100644 index 0000000..828a1e5 Binary files /dev/null and b/edge/game/art/en_cop_idle.png differ diff --git a/edge/game/art/en_drone.png b/edge/game/art/en_drone.png new file mode 100644 index 0000000..e772d42 Binary files /dev/null and b/edge/game/art/en_drone.png differ diff --git a/edge/game/art/en_drone_attack.png b/edge/game/art/en_drone_attack.png new file mode 100644 index 0000000..4655376 Binary files /dev/null and b/edge/game/art/en_drone_attack.png differ diff --git a/edge/game/art/en_drone_idle.png b/edge/game/art/en_drone_idle.png new file mode 100644 index 0000000..246ed9a Binary files /dev/null and b/edge/game/art/en_drone_idle.png differ diff --git a/edge/game/art/en_guard.png b/edge/game/art/en_guard.png new file mode 100644 index 0000000..4e5b3a4 Binary files /dev/null and b/edge/game/art/en_guard.png differ diff --git a/edge/game/art/en_guard_attack.png b/edge/game/art/en_guard_attack.png new file mode 100644 index 0000000..129e27d Binary files /dev/null and b/edge/game/art/en_guard_attack.png differ diff --git a/edge/game/art/en_guard_idle.png b/edge/game/art/en_guard_idle.png new file mode 100644 index 0000000..0ed56d4 Binary files /dev/null and b/edge/game/art/en_guard_idle.png differ diff --git a/edge/game/art/en_thug.png b/edge/game/art/en_thug.png new file mode 100644 index 0000000..bc52bb2 Binary files /dev/null and b/edge/game/art/en_thug.png differ diff --git a/edge/game/art/en_thug_attack.png b/edge/game/art/en_thug_attack.png new file mode 100644 index 0000000..da955e1 Binary files /dev/null and b/edge/game/art/en_thug_attack.png differ diff --git a/edge/game/art/en_thug_idle.png b/edge/game/art/en_thug_idle.png new file mode 100644 index 0000000..6034d92 Binary files /dev/null and b/edge/game/art/en_thug_idle.png differ diff --git a/edge/game/art/en_turret.png b/edge/game/art/en_turret.png new file mode 100644 index 0000000..2297128 Binary files /dev/null and b/edge/game/art/en_turret.png differ diff --git a/edge/game/art/en_turret_attack.png b/edge/game/art/en_turret_attack.png new file mode 100644 index 0000000..ca92e56 Binary files /dev/null and b/edge/game/art/en_turret_attack.png differ diff --git a/edge/game/art/en_turret_idle.png b/edge/game/art/en_turret_idle.png new file mode 100644 index 0000000..ce9e844 Binary files /dev/null and b/edge/game/art/en_turret_idle.png differ diff --git a/edge/game/art/manifest.json b/edge/game/art/manifest.json new file mode 100644 index 0000000..a43d1f5 --- /dev/null +++ b/edge/game/art/manifest.json @@ -0,0 +1,290 @@ +{ + "bg_apartment": { + "prompt": "small cramped one-room megacity apartment at night, side view interior, bunk bed, tiny kitchen corner, laundry on a line, one big window filling the back wall with dense neon city towers and holographic ads outside, warm lamp inside, detailed pixel art game background", + "seed": 71001, + "w": 240, + "h": 160 + }, + "bg_rooftop": { + "prompt": "rooftop of a megacity skyscraper at night seen from the roof surface, low concrete ledge in the foreground, vast glittering neon city sprawling far below, huge full moon dominating the sky, thin clouds, cold blue night with pink neon glow on the horizon, detailed pixel art game background, wide shot", + "seed": 71002, + "w": 240, + "h": 160 + }, + "bg_moon": { + "prompt": "gray lunar surface with fine dust and small craters, black star-filled sky, the blue Earth hanging large and luminous above the horizon, a small domed lunar base far in the distance, quiet and vast, detailed pixel art game background", + "seed": 71003, + "w": 240, + "h": 160 + }, + "bg_clinic": { + "prompt": "back-alley cybernetics clinic interior at night, side view, reclined operating chair under a ring of surgical lights, racks of chrome body parts and cables on the walls, monitors with green readouts, grimy floor, moody teal and magenta lighting, detailed pixel art game background", + "seed": 71004, + "w": 240, + "h": 160 + }, + "stage_alley": { + "prompt": "side view 2D action platformer stage, flat ground along the bottom, detailed pixel art game background, neon-soaked cyberpunk megacity at night, narrow back alley between towering buildings, wet asphalt reflecting pink and cyan neon signs, dumpsters, tangled cables overhead, steam vents, fire escapes, distant towers glowing between the buildings", + "seed": 71010, + "w": 384, + "h": 160 + }, + "stage_warehouse": { + "prompt": "side view 2D action platformer stage, flat ground along the bottom, detailed pixel art game background, neon-soaked cyberpunk megacity at night, corporate cargo warehouse interior at night, stacked shipping containers and crates, yellow gantry crane overhead, hazard stripes on the concrete floor, cold blue industrial lights, chain-link fencing, loading dock doors", + "seed": 71011, + "w": 384, + "h": 160 + }, + "stage_street": { + "prompt": "side view 2D action platformer stage, flat ground along the bottom, detailed pixel art game background, neon-soaked cyberpunk megacity at night, wide downtown avenue at night, holographic billboards and neon storefronts, parked futuristic cars, overturned barricades, palm trees under sodium lights, elevated rail line crossing above, smoke in the distance", + "seed": 71012, + "w": 384, + "h": 160 + }, + "stage_lab": { + "prompt": "side view 2D action platformer stage, flat ground along the bottom, detailed pixel art game background, neon-soaked cyberpunk megacity at night, sterile corporate laboratory corridor, white and gray panels, glass walls into server rooms glowing blue, robotic arms idle at workstations, red warning lights strobing, cable trays along the ceiling, polished floor", + "seed": 71013, + "w": 384, + "h": 160 + }, + "stage_pad": { + "prompt": "side view 2D action platformer stage, flat ground along the bottom, detailed pixel art game background, neon-soaked cyberpunk megacity at night, flat rooftop landing pad floor running continuously along the entire bottom edge of the image, helipad deck plating with painted markings as the walkable ground, low guard rails behind, antenna masts and vent stacks, distant skyscraper tops and thin clouds far below the railing, cold dawn sky in orange and steel blue", + "seed": 71114, + "w": 384, + "h": 160 + }, + "map_train": { + "prompt": "top-down 2D RPG map of a futuristic metro train car interior, seen directly from above like a Game Boy RPG town map, clean 16x16 tile alignment, top-down 2D RPG interior map, sleek silver car walls forming the border, rows of paired seats along both sides leaving a walkable center aisle, sliding doors at both ends, luggage racks, small floor lights, teal and gray palette", + "seed": 71020, + "w": 320, + "h": 160 + }, + "map_hideout": { + "prompt": "top-down 2D RPG map of a dive bar back room turned mercenary hideout, seen directly from above like a Game Boy RPG town map, clean 16x16 tile alignment, top-down 2D RPG interior map, dark brick walls forming the border, a long bar counter with stools along the top wall, a pool table in the middle, a weapons workbench, a torn couch and low table, neon sign glow spilling across the floor, crates in a corner", + "seed": 71021, + "w": 320, + "h": 240 + }, + "walk_david_s": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of lean latino teen, dark brown quiff hair with shaved sides, open yellow paramedic bomber jacket with green emblem, bare chest with gold chain necklaces, gray pants, white sneakers, standing", + "seed": 71030, + "w": 32, + "h": 32 + }, + "walk_david_n": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of lean latino teen, dark brown quiff hair with shaved sides, open yellow paramedic bomber jacket with green emblem, bare chest with gold chain necklaces, gray pants, white sneakers, standing", + "seed": 71031, + "w": 32, + "h": 32 + }, + "walk_david_e": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of lean latino teen, dark brown quiff hair with shaved sides, open yellow paramedic bomber jacket with green emblem, bare chest with gold chain necklaces, gray pants, white sneakers, standing", + "seed": 71032, + "w": 32, + "h": 32 + }, + "walk_lucy_s": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of pale slender young woman, white asymmetric bob haircut with pastel rainbow tips, cropped white jacket over black high-collar bodysuit with red accents, white shorts, gray stockings, black boots, standing", + "seed": 71033, + "w": 32, + "h": 32 + }, + "walk_lucy_n": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of pale slender young woman, white asymmetric bob haircut with pastel rainbow tips, cropped white jacket over black high-collar bodysuit with red accents, white shorts, gray stockings, black boots, standing", + "seed": 71034, + "w": 32, + "h": 32 + }, + "walk_lucy_e": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of pale slender young woman, white asymmetric bob haircut with pastel rainbow tips, cropped white jacket over black high-collar bodysuit with red accents, white shorts, gray stockings, black boots, standing", + "seed": 71035, + "w": 32, + "h": 32 + }, + "walk_maine_s": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of huge towering muscular black man with short blond hair, gunmetal chrome cyborg arms, olive tactical vest, standing", + "seed": 71036, + "w": 32, + "h": 32 + }, + "walk_maine_n": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of huge towering muscular black man with short blond hair, gunmetal chrome cyborg arms, olive tactical vest, standing", + "seed": 71037, + "w": 32, + "h": 32 + }, + "walk_maine_e": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of huge towering muscular black man with short blond hair, gunmetal chrome cyborg arms, olive tactical vest, standing", + "seed": 71038, + "w": 32, + "h": 32 + }, + "walk_rebecca_s": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of very short young woman with stark white skin, long turquoise-green twin tails, oversized black high-collar jacket with green accents, red and blue chunky robot arms, huge green and pink shotgun, standing", + "seed": 71039, + "w": 32, + "h": 32 + }, + "walk_rebecca_n": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of very short young woman with stark white skin, long turquoise-green twin tails, oversized black high-collar jacket with green accents, red and blue chunky robot arms, huge green and pink shotgun, standing", + "seed": 71040, + "w": 32, + "h": 32 + }, + "walk_rebecca_e": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of very short young woman with stark white skin, long turquoise-green twin tails, oversized black high-collar jacket with green accents, red and blue chunky robot arms, huge green and pink shotgun, standing", + "seed": 71041, + "w": 32, + "h": 32 + }, + "walk_falco_s": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of older man with semi-long dark brown hair parted in the middle, thick curled mustache, brown western waistcoat over white shirt, one silver chrome arm, standing", + "seed": 71042, + "w": 32, + "h": 32 + }, + "walk_kiwi_s": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of very tall slender woman, light blonde bob haircut, glossy red mask plate covering her lower face, long red coat, cyan web tattoos, standing", + "seed": 71043, + "w": 32, + "h": 32 + }, + "walk_gloria_s": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of tired woman in her forties, vivid red hair tied back, orange and white paramedic uniform, standing", + "seed": 71044, + "w": 32, + "h": 32 + }, + "walk_dorio_s": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of very tall muscular woman with short blonde hair, open dark jacket, black shorts, bare chrome metal legs, standing", + "seed": 71045, + "w": 32, + "h": 32 + }, + "walk_pilar_s": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of tall lanky man with a black mohawk, golden metal robot hands, goggle rig framing his jaw, sleeveless black vest, standing", + "seed": 71046, + "w": 32, + "h": 32 + }, + "act_david_idle": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of lean latino teen, dark brown quiff hair with shaved sides, open yellow paramedic bomber jacket with green emblem, bare chest with gold chain necklaces, gray pants, white sneakers, side view facing right, standing relaxed holding a compact pistol low", + "seed": 71050, + "w": 32, + "h": 32 + }, + "act_david_jump": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of lean latino teen, dark brown quiff hair with shaved sides, open yellow paramedic bomber jacket with green emblem, bare chest with gold chain necklaces, gray pants, white sneakers, side view facing right, mid-air jump with knees tucked, dynamic", + "seed": 71051, + "w": 32, + "h": 32 + }, + "act_david_shoot": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of lean latino teen, dark brown quiff hair with shaved sides, open yellow paramedic bomber jacket with green emblem, bare chest with gold chain necklaces, gray pants, white sneakers, side view facing right, two-handed pistol aimed straight forward, muzzle flash", + "seed": 71052, + "w": 32, + "h": 32 + }, + "act_david_hurt": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of lean latino teen, dark brown quiff hair with shaved sides, open yellow paramedic bomber jacket with green emblem, bare chest with gold chain necklaces, gray pants, white sneakers, side view facing right, flinching backward in pain, arms raised", + "seed": 71053, + "w": 32, + "h": 32 + }, + "en_thug_idle": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of street gang punk, tall red mohawk, spiked leather jacket, one chrome cyber arm, knuckle wraps, side view facing right, standing", + "seed": 71060, + "w": 32, + "h": 32 + }, + "en_thug_attack": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of street gang punk, tall red mohawk, spiked leather jacket, one chrome cyber arm, knuckle wraps, side view facing right, lunging forward throwing a punch with the chrome arm", + "seed": 71061, + "w": 32, + "h": 32 + }, + "en_guard_idle": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of corporate security soldier, dark navy armored uniform, mirrored visor helmet, compact rifle, side view facing right, standing", + "seed": 71062, + "w": 32, + "h": 32 + }, + "en_guard_attack": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of corporate security soldier, dark navy armored uniform, mirrored visor helmet, compact rifle, side view facing right, kneeling and firing the rifle, small muzzle flash", + "seed": 71063, + "w": 32, + "h": 32 + }, + "en_cop_idle": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of heavily armored tactical trooper, matte black exo armor, riot helmet with red lenses, side view facing right, standing", + "seed": 71064, + "w": 32, + "h": 32 + }, + "en_cop_attack": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of heavily armored tactical trooper, matte black exo armor, riot helmet with red lenses, side view facing right, braced and firing a heavy rifle, small muzzle flash", + "seed": 71065, + "w": 32, + "h": 32 + }, + "en_drone_idle": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of small hovering security drone, twin ducted rotors, single red sensor eye, gunmetal shell, side view facing right, standing", + "seed": 71066, + "w": 32, + "h": 32 + }, + "en_drone_attack": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of small hovering security drone, twin ducted rotors, single red sensor eye, gunmetal shell, side view facing right, tilted forward, red eye flaring, firing a shot downward", + "seed": 71067, + "w": 32, + "h": 32 + }, + "en_turret_idle": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of compact automated sentry gun turret on a squat tripod, twin barrels, amber sensor, side view facing right, standing", + "seed": 71068, + "w": 32, + "h": 32 + }, + "en_turret_attack": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of compact automated sentry gun turret on a squat tripod, twin barrels, amber sensor, side view facing right, barrels recoiling with a small muzzle flash", + "seed": 71069, + "w": 32, + "h": 32 + }, + "boss_smasher_idle": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of towering heavy full-metal chrome cyborg, skull-like silver faceplate, single red eye, enormous shoulders, gunmetal and black armor plating, arm cannon, side view facing right, standing menacingly, full body head to toe", + "seed": 71070, + "w": 64, + "h": 64 + }, + "boss_smasher_attack": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of towering heavy full-metal chrome cyborg, skull-like silver faceplate, single red eye, enormous shoulders, gunmetal and black armor plating, arm cannon, side view facing right, arm cannon raised and firing, muzzle flash, full body head to toe", + "seed": 71071, + "w": 64, + "h": 64 + }, + "spr_duo_ledge": { + "prompt": "tiny pixel art sprite of two people sitting side by side on a concrete rooftop ledge seen from behind, on the left a lean young man with undercut dark hair and yellow-black jacket, on the right a slender woman with silver-white bob hair and white jacket, feet dangling, night", + "seed": 71080, + "w": 64, + "h": 32 + }, + "spr_lucy_suit": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of a slender woman in a sleek white and red spacesuit without helmet, silver-white bob hair with pastel tips, walking, side view facing right", + "seed": 71081, + "w": 32, + "h": 32 + }, + "spr_gloria_fallen": { + "prompt": "tiny pixel art sprite of a woman in an orange and white paramedic uniform lying injured on the ground, side view, full body", + "seed": 71082, + "w": 32, + "h": 32 + }, + "spr_cyberskel": { + "prompt": "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite of a lean young man with undercut dark hair fused into a skeletal chrome exoskeleton frame, glowing cyan joints, side view facing right, standing", + "seed": 71083, + "w": 32, + "h": 32 + } +} \ No newline at end of file diff --git a/edge/game/art/map_hideout.png b/edge/game/art/map_hideout.png new file mode 100644 index 0000000..94259f5 Binary files /dev/null and b/edge/game/art/map_hideout.png differ diff --git a/edge/game/art/map_train.png b/edge/game/art/map_train.png new file mode 100644 index 0000000..379602d Binary files /dev/null and b/edge/game/art/map_train.png differ diff --git a/edge/game/art/spr_cyberskel.png b/edge/game/art/spr_cyberskel.png new file mode 100644 index 0000000..4629b57 Binary files /dev/null and b/edge/game/art/spr_cyberskel.png differ diff --git a/edge/game/art/spr_duo_ledge.png b/edge/game/art/spr_duo_ledge.png new file mode 100644 index 0000000..3fc645e Binary files /dev/null and b/edge/game/art/spr_duo_ledge.png differ diff --git a/edge/game/art/spr_gloria_fallen.png b/edge/game/art/spr_gloria_fallen.png new file mode 100644 index 0000000..8680de5 Binary files /dev/null and b/edge/game/art/spr_gloria_fallen.png differ diff --git a/edge/game/art/spr_lucy_suit.png b/edge/game/art/spr_lucy_suit.png new file mode 100644 index 0000000..2f832b6 Binary files /dev/null and b/edge/game/art/spr_lucy_suit.png differ diff --git a/edge/game/art/stage_alley.png b/edge/game/art/stage_alley.png new file mode 100644 index 0000000..bd27e0e Binary files /dev/null and b/edge/game/art/stage_alley.png differ diff --git a/edge/game/art/stage_lab.png b/edge/game/art/stage_lab.png new file mode 100644 index 0000000..3732f82 Binary files /dev/null and b/edge/game/art/stage_lab.png differ diff --git a/edge/game/art/stage_pad.png b/edge/game/art/stage_pad.png new file mode 100644 index 0000000..109af2a Binary files /dev/null and b/edge/game/art/stage_pad.png differ diff --git a/edge/game/art/stage_street.png b/edge/game/art/stage_street.png new file mode 100644 index 0000000..8b0dc73 Binary files /dev/null and b/edge/game/art/stage_street.png differ diff --git a/edge/game/art/stage_warehouse.png b/edge/game/art/stage_warehouse.png new file mode 100644 index 0000000..520e1d1 Binary files /dev/null and b/edge/game/art/stage_warehouse.png differ diff --git a/edge/game/art/walk_david_e.png b/edge/game/art/walk_david_e.png new file mode 100644 index 0000000..1a7004e Binary files /dev/null and b/edge/game/art/walk_david_e.png differ diff --git a/edge/game/art/walk_david_n.png b/edge/game/art/walk_david_n.png new file mode 100644 index 0000000..4d9113e Binary files /dev/null and b/edge/game/art/walk_david_n.png differ diff --git a/edge/game/art/walk_david_s.png b/edge/game/art/walk_david_s.png new file mode 100644 index 0000000..70b17df Binary files /dev/null and b/edge/game/art/walk_david_s.png differ diff --git a/edge/game/art/walk_dorio_s.png b/edge/game/art/walk_dorio_s.png new file mode 100644 index 0000000..9d78a75 Binary files /dev/null and b/edge/game/art/walk_dorio_s.png differ diff --git a/edge/game/art/walk_falco_s.png b/edge/game/art/walk_falco_s.png new file mode 100644 index 0000000..b024be9 Binary files /dev/null and b/edge/game/art/walk_falco_s.png differ diff --git a/edge/game/art/walk_gloria_s.png b/edge/game/art/walk_gloria_s.png new file mode 100644 index 0000000..8c159e3 Binary files /dev/null and b/edge/game/art/walk_gloria_s.png differ diff --git a/edge/game/art/walk_kiwi_s.png b/edge/game/art/walk_kiwi_s.png new file mode 100644 index 0000000..0c7e719 Binary files /dev/null and b/edge/game/art/walk_kiwi_s.png differ diff --git a/edge/game/art/walk_lucy_e.png b/edge/game/art/walk_lucy_e.png new file mode 100644 index 0000000..6dad470 Binary files /dev/null and b/edge/game/art/walk_lucy_e.png differ diff --git a/edge/game/art/walk_lucy_n.png b/edge/game/art/walk_lucy_n.png new file mode 100644 index 0000000..7916c42 Binary files /dev/null and b/edge/game/art/walk_lucy_n.png differ diff --git a/edge/game/art/walk_lucy_s.png b/edge/game/art/walk_lucy_s.png new file mode 100644 index 0000000..d2583d4 Binary files /dev/null and b/edge/game/art/walk_lucy_s.png differ diff --git a/edge/game/art/walk_maine_e.png b/edge/game/art/walk_maine_e.png new file mode 100644 index 0000000..7e31319 Binary files /dev/null and b/edge/game/art/walk_maine_e.png differ diff --git a/edge/game/art/walk_maine_n.png b/edge/game/art/walk_maine_n.png new file mode 100644 index 0000000..9f5524d Binary files /dev/null and b/edge/game/art/walk_maine_n.png differ diff --git a/edge/game/art/walk_maine_s.png b/edge/game/art/walk_maine_s.png new file mode 100644 index 0000000..7288899 Binary files /dev/null and b/edge/game/art/walk_maine_s.png differ diff --git a/edge/game/art/walk_pilar_s.png b/edge/game/art/walk_pilar_s.png new file mode 100644 index 0000000..ee05550 Binary files /dev/null and b/edge/game/art/walk_pilar_s.png differ diff --git a/edge/game/art/walk_rebecca_e.png b/edge/game/art/walk_rebecca_e.png new file mode 100644 index 0000000..5779c96 Binary files /dev/null and b/edge/game/art/walk_rebecca_e.png differ diff --git a/edge/game/art/walk_rebecca_n.png b/edge/game/art/walk_rebecca_n.png new file mode 100644 index 0000000..d7c9d65 Binary files /dev/null and b/edge/game/art/walk_rebecca_n.png differ diff --git a/edge/game/art/walk_rebecca_s.png b/edge/game/art/walk_rebecca_s.png new file mode 100644 index 0000000..2745a5d Binary files /dev/null and b/edge/game/art/walk_rebecca_s.png differ diff --git a/edge/game/art/wd_david.png b/edge/game/art/wd_david.png new file mode 100644 index 0000000..b38edfc Binary files /dev/null and b/edge/game/art/wd_david.png differ diff --git a/edge/game/art/wd_dorio.png b/edge/game/art/wd_dorio.png new file mode 100644 index 0000000..1d0482d Binary files /dev/null and b/edge/game/art/wd_dorio.png differ diff --git a/edge/game/art/wd_falco.png b/edge/game/art/wd_falco.png new file mode 100644 index 0000000..42d86f9 Binary files /dev/null and b/edge/game/art/wd_falco.png differ diff --git a/edge/game/art/wd_gloria.png b/edge/game/art/wd_gloria.png new file mode 100644 index 0000000..c66e2ed Binary files /dev/null and b/edge/game/art/wd_gloria.png differ diff --git a/edge/game/art/wd_kiwi.png b/edge/game/art/wd_kiwi.png new file mode 100644 index 0000000..24a4dea Binary files /dev/null and b/edge/game/art/wd_kiwi.png differ diff --git a/edge/game/art/wd_lucy.png b/edge/game/art/wd_lucy.png new file mode 100644 index 0000000..4854f97 Binary files /dev/null and b/edge/game/art/wd_lucy.png differ diff --git a/edge/game/art/wd_maine.png b/edge/game/art/wd_maine.png new file mode 100644 index 0000000..753d265 Binary files /dev/null and b/edge/game/art/wd_maine.png differ diff --git a/edge/game/art/wd_pilar.png b/edge/game/art/wd_pilar.png new file mode 100644 index 0000000..32bba37 Binary files /dev/null and b/edge/game/art/wd_pilar.png differ diff --git a/edge/game/art/wd_rebecca.png b/edge/game/art/wd_rebecca.png new file mode 100644 index 0000000..2e76329 Binary files /dev/null and b/edge/game/art/wd_rebecca.png differ diff --git a/edge/game/dossier.md b/edge/game/dossier.md new file mode 100644 index 0000000..0a67cc5 --- /dev/null +++ b/edge/game/dossier.md @@ -0,0 +1,104 @@ +# SEE YOU ON THE MOON — research dossier + +Fan-tribute source notes for the game script. The game is an unaffiliated, +non-commercial fan work based on *Cyberpunk: Edgerunners* (2022, Netflix ONA; +Studio Trigger × CD Projekt RED; created by Rafał Jaki, directed by Hiroyuki +Imaishi). All in-game dialogue is original fan writing in Chinese; a handful of +famous lines are echoed in translation and are marked below. + +## Canon the game compresses + +10 episodes, Night City, 2075–2076. David Martinez, a Santo Domingo teen at +Arasaka Academy, loses his mother Gloria (EMT; killed by a gang-crossfire car +crash; Trauma Team serves only subscribers). He has Doc implant the +military-grade **Sandevistan** she was carrying, humiliates the bully Katsuo, +gets expelled, and meets **Lucy** pickpocketing on the NCART train — she shares +a braindance of walking on the Moon, her dream. He joins **Maine's crew** +(Maine, Dorio, Kiwi, Pilar, Rebecca, Falco driving) via a trial job. Pilar dies +to a cyberpsycho; David promises Lucy the Moon. The Tanaka job (Ep 5–6) breaks +the crew: Maine slides into **cyberpsychosis**, Dorio dies, Maine detonates +himself telling David "this is the end of the line for me — not for you." +Timeskip: David leads the crew, chromed past his limits; Lucy secretly kills +the Arasaka netrunners hunting them. The fixer **Faraday** (with Kiwi's +betrayal) captures Lucy — bait to make David a **cyberskeleton** test subject. +David installs the rig, storms Arasaka Tower ("Mom, I made it"), frees Lucy — +and **Adam Smasher** intervenes: Rebecca is crushed, David is executed at the +plaza after buying Lucy's escape, leaving her an apology via Falco: sorry we +couldn't go to the Moon together. Epilogue: Lucy on the lunar surface, alone, +at the spot from the braindance. + +Game compression choices (fan liberties, deliberate): +- Katsuo's beatdown becomes an off-screen caption; the tutorial stage is an + invented Tyger Claws alley ambush after the install. +- The trial job (Maxim Kuznetsov data steal, Ep 3) becomes the warehouse stage. +- The Moon braindance (Ep 2) and the promise (Ep 4) merge into one rooftop + scene — the insert-song scene. +- Tanaka job fallout + Maine's end compress into the street stage. +- Ep 7–9 (timeskip, Kiwi's betrayal, Lucy captured, cyberskeleton) compress + into captions + the lab stage. +- The tower fight ends scripted at half of Smasher's HP: canon says David + cannot win this fight. Rebecca's death is conveyed by caption, not gore. + +## Character sheets used for art (verified looks) + +- David: brown quiff w/ shaved sides, mother's **yellow EMT bomber jacket** + (green emblem), gold chains, gray pants. Sandevistan spine implant. +- Lucy: white asymmetric bob w/ pastel rainbow tips, cropped white jacket, + black high-collar leotard w/ red accents, white shorts, gray stockings. +- Rebecca: tiny, stark-white skin, turquoise twin tails, oversized black + jacket, red/blue Gorilla cyberarms, green-pink shotgun ("Guts"). +- Maine: huge Black man, **short blond hair**, gunmetal chrome arms. +- Dorio: very tall blonde woman, open jacket, chrome legs. +- Kiwi: light-blonde bob, **red plate mask over her missing jaw**, red coat. +- Falco: brown middle-part + thick mustache, western waistcoat, chrome arm. +- Gloria: vivid red hair, orange/white paramedic uniform. +- Adam Smasher: towering chrome full-borg, skull-like faceplate, red optics, + arm cannon; has his own Sandevistan. +- Sandevistan visual: world desaturates; David trails rainbow-echo + afterimages. In-game: cyan-teal palette swap + OBJ-blend ghosts. + +## Lines echoed in translation (everything else is original writing) + +- "I'll take you to the moon! I promise!" (David, Ep 4) +- "This is it — end of the line for me. But not for you." / "Just keep + running." (Maine, Ep 6) +- "Use the cyberskeleton if you want to survive." (fake-Lucy, Ep 9) +- "You never had to save me. All I ever wanted was for you to live." + (Lucy, Ep 10 — quote-site wording, not script-verified) +- "Whoa — check this! I can feel the sun!" (David's braindance echo, Ep 2/10) +- Kiwi's creed: "Never trust a soul in Night City." +- Epitaph motif (2077 columbarium): "You were right. David reached the top of + Arasaka Tower." / "You didn't take me to the moon, but you were there with + me." + +## The insert song + +"I Really Want to Stay at Your House" — written/performed/produced by **Rosa +Walton** feat. Hallie Coggins; *Cyberpunk 2077: Radio, Vol. 2* (2020); ~125 +BPM, ≈E♭ minor. In the show it closes **Ep 2** (David & Lucy's episode, by +director Imaishi's choice) and scores the **Ep 10** climax and Moon epilogue — +the David-and-Lucy theme. The game streams 8-bit fan covers as GBA DirectSound +PCM: the rooftop scene uses Sammeh12's GXSCC Famicom-style cover; the epilogue +uses an excerpt of Raxlen Slice's chiptune remix. Cover audio is used for +private fan purposes only and must not ship in any distributed build. + +## Do-not-state-as-fact ledger + +- "Wake up, samurai" is 2077/Johnny Silverhand canon — David never says it; + the game does not use it. +- Exact rooftop-kiss staging, neon color breakdowns, some quote wordings are + reconstruction, not script citations; the game treats them as mood only. +- The merc bar in the anime is the Afterlife (Watson). The game's hideout is + a generic dive-bar back room and never uses the name in art; dialogue may + reference "酒吧后间" without trade dress. + +## Sources + +Wikipedia: en.wikipedia.org/wiki/Cyberpunk:_Edgerunners · +en.wikipedia.org/wiki/I_Really_Want_to_Stay_at_Your_House · +Fandom (episode/character pages): cyberpunk.fandom.com/wiki/My_Moon_My_Man, +/David_Martinez, /Lucyna_Kushinada, /Rebecca, /Maine_(Edgerunners), /Dorio_Gunnarsdóttir, +/Kiwi, /Pilar, /Falco, /Faraday, /Adam_Smasher, /Gloria_Martinez, /Katsuo_Tanaka, +/Doc_(Edgerunners), /Afterlife, /Guts, /Megabuilding_H4, +/I_Really_Want_to_Stay_at_Your_House · tunebat.com (BPM/key) · +screenrant.com/cyberpunk-edgerunners-netflix-best-quotes-lines/ diff --git a/edge/game/music/README.md b/edge/game/music/README.md new file mode 100644 index 0000000..bbf0464 --- /dev/null +++ b/edge/game/music/README.md @@ -0,0 +1,25 @@ +# Insert-song audio (not committed) + +The rooftop and moon scenes stream an 8-bit cover of *"I Really Want to Stay at +Your House"* (Rosa Walton feat. Hallie Coggins). Those `.raw` PCM files are +**git-ignored on purpose** — they are third-party fan covers, used here for a +private, non-commercial tribute only. Do not commit them or distribute a built +ROM that embeds them. + +To make the full game build locally, place two files here: + +- `stay-gxscc.raw` — rooftop track (looping) +- `stay-raxlen-45s.raw` — moon reprise (looping) + +Each is signed 8-bit mono PCM at 13379 Hz. From any source audio: + +``` +ffmpeg -i cover.m4a -ac 1 -ar 13379 -f s8 stay-gxscc.raw +# a 45s excerpt: -ss 0 -t 45 +``` + +13379 Hz is the classic GBA DirectSound rate (exactly 224 samples per frame). +Tracks are streamed by DMA1 into FIFO A on Timer 0 (`runtime/audio.c`) and +embedded at link time via `.incbin` (`compiler/emit.ts::emitGenMusic`). The +committed engine tests use a procedurally-generated square-wave track instead, +so `bun run test:engine` needs none of this. diff --git a/edge/game/see-you-on-the-moon.ts b/edge/game/see-you-on-the-moon.ts new file mode 100644 index 0000000..0880816 --- /dev/null +++ b/edge/game/see-you-on-the-moon.ts @@ -0,0 +1,723 @@ +// SEE YOU ON THE MOON —《月球见》 +// A Cyberpunk: Edgerunners fan-tribute GBA game, built on @pocketjs/edge. +// +// Unaffiliated, non-commercial fan work. Original Chinese dialogue throughout; +// a handful of famous lines are echoed in translation (see game/dossier.md, +// which also carries the source ledger and every compression liberty taken). +// Music: 8-bit fan covers of "I Really Want to Stay at Your House" +// (Rosa Walton feat. Hallie Coggins), streamed as DirectSound PCM. +// +// Structure (12 scenes, action-first): +// title → apartment → clinic → ALLEY(action) → train(world) → +// hideout(world) → WAREHOUSE(action) → rooftop(song) → STREET(action) → +// LAB(action) → TOWER(boss) → moon(credits) + +import { + defineFilm, defineScene, cue, image, gradient, sprite, track, + fadeIn, fadeOut, wait, waitA, waitTweens, caption, captionClear, dialog, choice, + pan, panY, letterbox, mosaicTo, shake, alpha, zoom, show, hide, animate, moveTo, + walkTo, mash, counter, affineOn, sfx, setFlag, hasFlag, setVar, addVar, varEq, varGt, + rasterWave, rasterOff, world, meterShow, meterHide, warp, face, walk, + action, music, autoScroll, +} from "@pocketjs/edge"; + +// --------------------------------------------------------------------------------- +// S0 — title +// --------------------------------------------------------------------------------- +const title = defineScene({ + id: "title", + main: image("art/bg_rooftop.png"), + backdrop: "#04040c", + letterbox: 24, + play: cue(function* () { + yield fadeIn(50); + yield wait(30); + yield caption("card", "SEE YOU ON THE MOON\n《月球见》"); + yield wait(40); + yield caption("chip", "EDGERUNNERS 同人致敬"); + yield caption("sub", "非官方同人作品·剧本原创\n按 A 开始"); + yield waitA(); + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------------- +// S1 — apartment: Gloria and the crash (Ep 1 compressed) +// --------------------------------------------------------------------------------- +const apartment = defineScene({ + id: "apartment", + main: image("art/bg_apartment.png"), + backdrop: "#060612", + actors: { + david: sprite("art/wd_david.png", { w: 32, h: 32, frames: 12, walkFpd: 4 }), + gloria: sprite("art/wd_gloria.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + fallen: sprite("art/spr_gloria_fallen.png", { w: 32, h: 32 }), + }, + play: cue(function* () { + yield fadeIn(40); + yield caption("chip", "夜之城·圣多明戈 2075"); + yield show("david", 60, 112); + yield show("gloria", 150, 112); + yield dialog("GLORIA", "又打架了? 大卫,\n学费不是大风刮来的。"); + yield dialog("DAVID", "那学院里全是公司崽,\n他们看我们的眼神像看垃圾。"); + yield dialog("GLORIA", "忍着。总有一天,\n你会站上荒坂塔的顶层。"); + yield dialog("DAVID", "那是你的梦, 妈。"); + yield dialog("GLORIA", "上车吧, 送你上学。"); + yield captionClear("chip"); + yield fadeOut(30); + // the crash — conveyed, not shown + yield wait(20); + yield shake(6, 50); + yield sfx("whoosh"); + yield caption("card", "帮派火并·流弹\n一场再普通不过的车祸"); + yield wait(80); + yield captionClear("all"); + yield fadeIn(40); + yield hide("gloria"); + yield hide("david"); + yield show("fallen", 120, 112); + yield caption("sub", "创伤小组降落, 扫了一眼\n她不是白金客户。又飞走了。"); + yield waitA(); + yield show("david", 80, 112); + yield walkTo("david", 104, 60); + yield dialog("DAVID", "妈?! 妈——"); + yield caption("sub", "第二天, 廉价诊所。\n葛洛莉亚没有醒来。"); + yield waitA(); + yield captionClear("all"); + yield fadeOut(50); + }), +}); + +// --------------------------------------------------------------------------------- +// S2 — clinic: the Sandevistan (Ep 1 end) +// --------------------------------------------------------------------------------- +const clinic = defineScene({ + id: "clinic", + main: image("art/bg_clinic.png"), + backdrop: "#080410", + actors: { + david: sprite("art/wd_david.png", { w: 32, h: 32, frames: 12, walkFpd: 4 }), + }, + play: cue(function* () { + yield fadeIn(40); + yield caption("chip", "后巷诊所"); + yield show("david", 104, 112); + yield caption("sub", "妈妈的遗物里有件军规义体:\n斯安威斯坦, 时间加速器。"); + yield waitA(); + yield dialog("DOC", "军用货。普通人的身体\n撑不过三天就得进焚化炉。"); + yield dialog("DAVID", "装。"); + yield dialog("DOC", "你妈刚走, 你就急着找死?"); + yield dialog("DAVID", "我一无所有了, 医生。\n装上它。"); + yield captionClear("all"); + yield mosaicTo(10, 30); + yield shake(4, 40); + yield sfx("star"); + yield wait(40); + yield mosaicTo(0, 30); + yield caption("card", "SANDEVISTAN\n脊椎连接·完成"); + yield wait(70); + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------------- +// S3 — ALLEY: tutorial action stage (fan liberty: Tyger Claws ambush) +// --------------------------------------------------------------------------------- +const alley = defineScene({ + id: "alley", + main: image("art/stage_alley.png", { wide: true }), + backdrop: "#0a0618", + actors: { + david: sprite("art/act_david.png", { w: 32, h: 32, frames: 8 }), + thug: sprite("art/en_thug.png", { w: 32, h: 32, frames: 4 }), + }, + action: { + player: { actor: "david", hp: 6, sande: 32 }, + ground: 140, + gates: [ + { x: 170, wave: 1 }, + { x: 290, wave: 2 }, + ], + spawns: [ + { type: "thug", actor: "thug", x: 210, hp: 2, wave: 1 }, + { type: "thug", actor: "thug", x: 320, hp: 2, wave: 2 }, + { type: "thug", actor: "thug", x: 350, hp: 3, wave: 2 }, + ], + exit: "clear", + }, + play: cue(function* () { + yield fadeIn(30); + yield caption("chip", "回街区的路上"); + yield dialog("混混", "哟, 学院的小少爷。\n把外套和芯片都留下。"); + yield dialog("DAVID", "…今天真不是好日子。"); + yield captionClear("all"); + yield caption("sub", "十字键移动 A跳 B射击近战\n按住R: 义体超频·子弹时间"); + yield meterShow(0, "hp", 160, 4, 6); + yield meterShow(1, "sande", 160, 14, 32); + yield action(); + yield meterHide(0); + yield meterHide(1); + yield captionClear("all"); + if (yield varGt("deaths", 0)) { + yield caption("sub", "身体在报警, 但时间…\n时间站在他这边。"); + } else { + yield caption("sub", "十秒。他们甚至没碰到他。"); + } + yield waitA(); + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------------- +// S4 — train: meeting Lucy (Ep 2) +// --------------------------------------------------------------------------------- +const train = defineScene({ + id: "train", + main: image("art/map_train.png"), + backdrop: "#08101c", + actors: { + david: sprite("art/wd_david.png", { w: 32, h: 32, frames: 12, walkFpd: 4 }), + lucy: sprite("art/wd_lucy.png", { w: 32, h: 32, frames: 12, walkFpd: 4 }), + }, + world: { + grid: [ + "####################", + "####################", + "#########..#########", + "#########..#########", + "##..............l.##", + "#p................d#", + "##................##", + "##................##", + "#########..#########", + "####################", + ], + player: { actor: "david", at: "p", dir: "right" }, + npcs: { + lucy: { + actor: "lucy", + at: "l", + dir: "left", + talk: cue(function* () { + if (yield hasFlag("met_lucy")) { + yield dialog("LUCY", "跟紧点, 别像个游客。"); + return; + } + yield setFlag("met_lucy"); + yield caption("sub", "指尖擦过口袋, 世界慢了。\n你在时间里抓住她的手腕。"); + yield waitA(); + yield captionClear("all"); + yield dialog("LUCY", "…斯安威斯坦?\n有意思, 你不是公司的人。"); + yield dialog("DAVID", "你在偷我东西。"); + yield dialog("LUCY", "偷的是你旁边那位\n公司高管的加密芯片。"); + yield dialog("LUCY", "他的保镖上车了。想活命\n就跟我走, 快。"); + yield caption("sub", "去车厢尽头的门。"); + yield waitA(); + yield captionClear("all"); + }), + }, + }, + spots: { + window: { + at: [9, 1, 2, 1], + run: cue(function* () { + yield caption("sub", "车窗外月亮悬在城市上空。\n妈妈说, 顶层的人离它最近。"); + yield waitA(); + yield captionClear("all"); + }), + }, + }, + exits: { door: { at: "d", value: 1 } }, + }, + play: cue(function* () { + yield fadeIn(40); + yield caption("chip", "NCART 高架列车"); + yield world(); + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------------- +// S5 — hideout: Maine's crew (Ep 2-3 compressed) +// --------------------------------------------------------------------------------- +const hideout = defineScene({ + id: "hideout", + main: image("art/map_hideout.png"), + backdrop: "#0c0810", + actors: { + david: sprite("art/wd_david.png", { w: 32, h: 32, frames: 12, walkFpd: 4 }), + lucy: sprite("art/wd_lucy.png", { w: 32, h: 32, frames: 12, walkFpd: 4 }), + maine: sprite("art/wd_maine.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + rebecca: sprite("art/wd_rebecca.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + dorio: sprite("art/wd_dorio.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + pilar: sprite("art/wd_pilar.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + kiwi: sprite("art/wd_kiwi.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + falco: sprite("art/wd_falco.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + }, + world: { + grid: [ + "####################", + "####################", + "####################", + "#..................#", + "#..m....o......k...#", + "#..................#", + "#..................#", + "#.....#######......#", + "#.....#######..l...#", + "#.##..............##", + "#.##..r..q.......###", + "#..................#", + "#...........f......#", + "#.........p........#", + "#########d##########", + ], + player: { actor: "david", at: "p", dir: "up" }, + npcs: { + maine: { + actor: "maine", + at: "m", + dir: "down", + talk: cue(function* () { + if (yield hasFlag("joined")) { + yield dialog("MAINE", "仓库见, 新人。\n别迟到。"); + return; + } + yield dialog("MAINE", "露西说你抢了她要的芯片,\n还带着一根军规脊椎。"); + yield dialog("DAVID", "我要入伙。"); + yield dialog("MAINE", "边缘行者不是过家家。\n为什么?"); + const why = yield choice(["我要活下去", "我要往上爬", "为了她"]); + if (why === 2) { + yield dialog("MAINE", "哈! 至少够诚实。"); + } else { + yield dialog("MAINE", "在夜之城, 这两句\n是同一句话。"); + } + yield dialog("MAINE", "试用任务: 军用科技的仓库,\n拿到货, 活着回来。"); + yield dialog("MAINE", "薪水平分, 后背互相照应。\n这是规矩。"); + yield setFlag("joined"); + yield caption("sub", "走出后门, 前往仓库。"); + yield waitA(); + yield captionClear("all"); + }), + }, + rebecca: { + actor: "rebecca", + at: "r", + dir: "right", + talk: cue(function* () { + yield dialog("REBECCA", "新来的? 枪拿稳点,\n别挡我姐的道。"); + yield dialog("REBECCA", "…开玩笑的。死了太浪费,\n你脸蛋还不错。"); + }), + }, + dorio: { + actor: "dorio", + at: "o", + dir: "down", + talk: cue(function* () { + yield dialog("DORIO", "缅因嘴硬心软。\n他说平分, 就真的平分。"); + }), + }, + pilar: { + actor: "pilar", + at: "q", + dir: "down", + talk: cue(function* () { + yield dialog("PILAR", "嘿新人, 猜猜这双手\n多少钱? 猜不到就请喝酒。"); + }), + }, + kiwi: { + actor: "kiwi", + at: "k", + dir: "down", + talk: cue(function* () { + yield dialog("KIWI", "夜之城的规矩只有一条:\n别相信任何人。"); + }), + }, + falco: { + actor: "falco", + at: "f", + dir: "down", + talk: cue(function* () { + yield dialog("FALCO", "车我来开。你只管\n活着跑回来。"); + }), + }, + lucy: { + actor: "lucy", + at: "l", + dir: "left", + talk: cue(function* () { + yield dialog("LUCY", "别死在第一单, 大卫。\n那样太无聊了。"); + }), + }, + }, + exits: { back: { at: "d", value: 1 } }, + }, + play: cue(function* () { + yield fadeIn(40); + yield caption("chip", "酒吧后间·据点"); + yield world(); + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------------- +// S6 — WAREHOUSE: the trial job (Ep 3 compressed) +// --------------------------------------------------------------------------------- +const warehouse = defineScene({ + id: "warehouse", + main: image("art/stage_warehouse.png", { wide: true }), + backdrop: "#060a12", + actors: { + david: sprite("art/act_david.png", { w: 32, h: 32, frames: 8 }), + guard: sprite("art/en_guard.png", { w: 32, h: 32, frames: 4 }), + drone: sprite("art/en_drone.png", { w: 32, h: 32, frames: 4 }), + turret: sprite("art/en_turret.png", { w: 32, h: 32, frames: 4 }), + rebecca: sprite("art/wd_rebecca.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + }, + action: { + player: { actor: "david", hp: 6, sande: 32 }, + ground: 140, + gates: [ + { x: 150, wave: 1 }, + { x: 280, wave: 2 }, + ], + spawns: [ + { type: "gunner", actor: "guard", x: 190, hp: 2, wave: 1 }, + { type: "drone", actor: "drone", x: 240, hp: 2, wave: 1 }, + { type: "gunner", actor: "guard", x: 310, hp: 3, wave: 2 }, + { type: "turret", actor: "turret", x: 356, hp: 4, wave: 2 }, + { type: "drone", actor: "drone", x: 330, hp: 2, wave: 2 }, + ], + exit: "clear", + }, + play: cue(function* () { + yield fadeIn(30); + yield caption("chip", "军用科技·四号仓库"); + yield show("rebecca", 40, 104); + yield dialog("REBECCA", "警报响了, 新人!\n货在最里面, 杀出去!"); + yield hide("rebecca"); + yield captionClear("all"); + yield meterShow(0, "hp", 160, 4, 6); + yield meterShow(1, "sande", 160, 14, 32); + yield action(); + yield meterHide(0); + yield meterHide(1); + yield captionClear("all"); + yield show("rebecca", 300, 104); + if (yield varGt("deaths", 0)) { + yield dialog("REBECCA", "活着就行! 缅因说了,\n死人拿不到分成。"); + } else { + yield dialog("REBECCA", "哇哦。一滴血都没流?\n我开始喜欢你了, 新人。"); + } + yield dialog("DAVID", "货到手了。回去吧。"); + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------------- +// S7 — rooftop: the moon, the promise, the song (Ep 2+4 merged) ★ insert song +// --------------------------------------------------------------------------------- +const rooftop = defineScene({ + id: "rooftop", + main: image("art/bg_rooftop.png"), + backdrop: "#04040c", + letterbox: 20, + actors: { + duo: sprite("art/spr_duo_ledge.png", { w: 64, h: 32 }), + }, + play: cue(function* () { + yield music("stay"); + yield fadeIn(60); + yield caption("chip", "据点楼顶·凌晨三点"); + yield show("duo", 88, 96); + yield wait(90); + yield dialog("LUCY", "小时候我总在网络里\n看同一段脑舞。"); + yield dialog("LUCY", "月球。银色的地面,\n刺眼的太阳, 没有网。"); + yield dialog("DAVID", "为什么是月球?"); + yield dialog("LUCY", "在这座城市, 连月亮\n都是别人卖给你的广告。"); + yield dialog("LUCY", "但真正的月球上, 没有人\n能追踪你。自由。"); + yield wait(40); + yield dialog("DAVID", "那我带你去。"); + yield dialog("LUCY", "…你知道票价吗, 笨蛋?"); + const p = yield choice(["我保证", "一定带你去"]); + yield setFlag("promise"); + if (p === 0) { + yield dialog("DAVID", "我带你去月球。我保证。"); + } else { + yield dialog("DAVID", "等着吧。总有一天,\n我们一起去。"); + } + yield dialog("LUCY", "…笨蛋。"); + yield captionClear("all"); + yield caption("sub", "♪ I Really Want to Stay\nAt Your House ♪"); + yield wait(180); + yield captionClear("all"); + yield wait(120); + yield fadeOut(80); + yield music("off"); + }), +}); + +// --------------------------------------------------------------------------------- +// S8 — STREET: the Tanaka job falls apart; Maine's end (Ep 5-6 compressed) +// --------------------------------------------------------------------------------- +const street = defineScene({ + id: "street", + main: image("art/stage_street.png", { wide: true }), + backdrop: "#0a0814", + actors: { + david: sprite("art/act_david.png", { w: 32, h: 32, frames: 8 }), + cop: sprite("art/en_cop.png", { w: 32, h: 32, frames: 4 }), + drone: sprite("art/en_drone.png", { w: 32, h: 32, frames: 4 }), + maine: sprite("art/wd_maine.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + }, + action: { + player: { actor: "david", hp: 8, sande: 40 }, + ground: 140, + gates: [ + { x: 130, wave: 1 }, + { x: 230, wave: 2 }, + { x: 310, wave: 3 }, + ], + spawns: [ + { type: "gunner", actor: "cop", x: 170, hp: 3, wave: 1 }, + { type: "gunner", actor: "cop", x: 200, hp: 3, wave: 1 }, + { type: "drone", actor: "drone", x: 260, hp: 2, wave: 2 }, + { type: "gunner", actor: "cop", x: 300, hp: 3, wave: 2 }, + { type: "gunner", actor: "cop", x: 340, hp: 3, wave: 3 }, + { type: "drone", actor: "drone", x: 360, hp: 2, wave: 3 }, + ], + exit: "clear", + }, + play: cue(function* () { + yield fadeIn(30); + yield caption("chip", "日本町·撤离路线"); + yield caption("sub", "田中的任务是个陷阱。\n公司在收网, 全员突围!"); + yield waitA(); + yield captionClear("all"); + yield meterShow(0, "hp", 160, 4, 8); + yield meterShow(1, "sande", 160, 14, 40); + yield action(); + yield meterHide(0); + yield meterHide(1); + yield captionClear("all"); + // Maine's last stand + yield show("maine", 330, 104); + yield walkTo("david", 280, 40); + yield dialog("MAINE", "多里奥没跟上来。\n我回去找她。"); + yield dialog("DAVID", "缅因, 你的义体已经——"); + yield dialog("MAINE", "我知道。我早就知道。"); + yield dialog("MAINE", "听着, 大卫。这里是\n我的终点站——但不是你的。"); + yield dialog("MAINE", "继续跑。别停下来。"); + yield captionClear("all"); + yield wait(30); + yield fadeOut(20, "white"); + yield shake(6, 60); + yield sfx("whoosh"); + yield wait(60); + yield caption("card", "那天之后, 没有人再见过\n缅因和多里奥。"); + yield wait(100); + yield captionClear("all"); + yield fadeIn(1); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------------- +// S9 — LAB: six months later; the cyberskeleton raid (Ep 7-9 compressed) +// --------------------------------------------------------------------------------- +const lab = defineScene({ + id: "lab", + main: image("art/stage_lab.png", { wide: true }), + backdrop: "#0a0e16", + actors: { + david: sprite("art/act_david.png", { w: 32, h: 32, frames: 8 }), + guard: sprite("art/en_guard.png", { w: 32, h: 32, frames: 4 }), + turret: sprite("art/en_turret.png", { w: 32, h: 32, frames: 4 }), + drone: sprite("art/en_drone.png", { w: 32, h: 32, frames: 4 }), + skel: sprite("art/spr_cyberskel.png", { w: 32, h: 32 }), + }, + action: { + player: { actor: "david", hp: 10, sande: 48 }, + ground: 140, + gates: [ + { x: 120, wave: 1 }, + { x: 260, wave: 2 }, + ], + spawns: [ + { type: "gunner", actor: "guard", x: 160, hp: 3, wave: 1 }, + { type: "turret", actor: "turret", x: 200, hp: 4, wave: 1 }, + { type: "drone", actor: "drone", x: 180, hp: 2, wave: 1 }, + { type: "gunner", actor: "guard", x: 300, hp: 3, wave: 2 }, + { type: "gunner", actor: "guard", x: 330, hp: 3, wave: 2 }, + { type: "turret", actor: "turret", x: 364, hp: 4, wave: 2 }, + { type: "drone", actor: "drone", x: 344, hp: 2, wave: 2 }, + ], + exit: "clear", + }, + play: cue(function* () { + yield fadeIn(30); + yield caption("chip", "六个月后"); + yield caption("sub", "大卫成了传说, 也成了实验品\n候选。基维出卖了所有人。"); + yield waitA(); + yield caption("sub", "露西被抓走了。通讯里\n她的声音说: 想活下来——"); + yield waitA(); + yield captionClear("all"); + yield show("skel", 40, 104); + yield dialog("???", "「想活下来, 就穿上\n机械骨骼。」"); + yield dialog("DAVID", "…那不是露西。\n但没有别的路了。"); + yield hide("skel"); + yield mosaicTo(8, 20); + yield shake(5, 40); + yield sfx("star"); + yield mosaicTo(0, 20); + yield caption("sub", "机械骨骼·同步率异常\n免疫抑制剂: 九倍剂量"); + yield waitA(); + yield captionClear("all"); + yield meterShow(0, "hp", 160, 4, 10); + yield meterShow(1, "sande", 160, 14, 48); + yield action(); + yield meterHide(0); + yield meterHide(1); + yield captionClear("all"); + yield rasterWave("main", 2); + yield caption("sub", "视野边缘开始闪烁。\n他知道这意味着什么。"); + yield waitA(); + yield rasterOff(); + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------------- +// S10 — TOWER: Adam Smasher (Ep 10). The fight ends scripted: canon says you lose. +// --------------------------------------------------------------------------------- +const tower = defineScene({ + id: "tower", + main: image("art/stage_pad.png", { wide: true }), + backdrop: "#0c0a14", + actors: { + david: sprite("art/act_david.png", { w: 32, h: 32, frames: 8 }), + smasher: sprite("art/boss_smasher.png", { w: 64, h: 64, frames: 4 }), + cop: sprite("art/en_cop.png", { w: 32, h: 32, frames: 4 }), + lucy: sprite("art/wd_lucy.png", { w: 32, h: 32, frames: 12, walkFpd: 4 }), + rebecca: sprite("art/wd_rebecca.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + }, + action: { + player: { actor: "david", hp: 10, sande: 48 }, + ground: 140, + gates: [{ x: 120, wave: 1 }], + spawns: [ + { type: "gunner", actor: "cop", x: 150, hp: 3, wave: 1 }, + { type: "gunner", actor: "cop", x: 180, hp: 3, wave: 1 }, + { type: "boss", actor: "smasher", x: 300, hp: 40 }, + ], + bossPhaseHp: 20, + exit: "clear", + }, + play: cue(function* () { + yield fadeIn(40); + yield caption("chip", "荒坂塔·停机坪"); + yield show("rebecca", 60, 104); + yield dialog("REBECCA", "我数了一下, 一整个\n公司想弄死我们。"); + yield dialog("REBECCA", "…正合我意! 上吧!"); + yield hide("rebecca"); + yield dialog("DAVID", "妈, 你看。\n我到顶层了。"); + yield captionClear("all"); + yield meterShow(0, "hp", 160, 4, 10); + yield meterShow(1, "sande", 160, 14, 48); + const endfight = yield action(); + yield setVar("endfight", endfight); + yield meterHide(0); + yield meterHide(1); + yield captionClear("all"); + // scripted: the Sandevistan burns out; canon takes over + yield rasterWave("main", 3); + yield mosaicTo(6, 30); + yield shake(5, 50); + yield dialog("SMASHER", "初级货色的义体。\n你差点让我觉得有趣。"); + yield caption("sub", "斯安威斯坦过热·熔断\n免疫系统: 崩溃"); + yield waitA(); + yield caption("sub", "瑞贝卡的枪声停了。\n停机坪安静得可怕。"); + yield waitA(); + yield captionClear("all"); + yield mosaicTo(0, 20); + yield rasterOff(); + yield show("lucy", 40, 104); + yield walkTo("lucy", 96, 50); + yield dialog("LUCY", "大卫! 飞行器就在这,\n我们还能——"); + yield dialog("DAVID", "露西。你从来不需要我救。"); + yield dialog("DAVID", "我只是想看你\n站在月球上。"); + yield dialog("DAVID", "对不起。这次\n不能陪你去了。"); + yield dialog("LUCY", "我要的从来不是月球。\n我要的是你活着!"); + yield dialog("DAVID", "……月球见, 露西。"); + yield captionClear("all"); + yield wait(40); + yield fadeOut(90, "white"); + yield wait(30); + }), +}); + +// --------------------------------------------------------------------------------- +// S11 — moon: epilogue + credits ★ song reprise +// --------------------------------------------------------------------------------- +const moon = defineScene({ + id: "moon", + main: image("art/bg_moon.png"), + backdrop: "#000008", + letterbox: 20, + actors: { + lucy: sprite("art/spr_lucy_suit.png", { w: 32, h: 32 }), + }, + play: cue(function* () { + yield music("stay45"); + yield fadeIn(90, "white"); + yield caption("chip", "月球·静海"); + yield show("lucy", -20, 112); + yield walkTo("lucy", 100, 160); + yield wait(60); + yield caption("sub", "银色的地面。刺眼的太阳。\n和脑舞里一模一样。"); + yield waitA(); + yield dialog("LUCY", "看到了吗, 大卫。\n我不是一个人来的。"); + yield wait(60); + yield caption("sub", "「哇——你看!\n我能感觉到太阳!」"); + yield waitA(); + yield captionClear("all"); + yield wait(60); + // credits + yield caption("card", "SEE YOU ON THE MOON\n《月球见》"); + yield wait(140); + yield caption("card", "CYBERPUNK: EDGERUNNERS\n同人致敬·非官方作品"); + yield wait(140); + yield caption("card", "原作: Trigger x CDPR\n剧本为原创同人写作"); + yield wait(140); + yield caption("card", "插曲: I Really Want to\nStay at Your House"); + yield wait(140); + yield caption("card", "曲: Rosa Walton\n8-bit 翻奏仅私人使用"); + yield wait(140); + yield caption("card", "引擎: @pocketjs/edge\nGBA 上见"); + yield wait(140); + yield captionClear("all"); + if (yield hasFlag("promise")) { + yield caption("card", "他遵守了约定。"); + yield wait(160); + } + yield captionClear("all"); + yield fadeOut(80); + }), +}); + +// --------------------------------------------------------------------------------- +export default defineFilm({ + title: "SEE YOU ON THE MOON", + scenes: [title, apartment, clinic, alley, train, hideout, warehouse, rooftop, street, lab, tower, moon], + music: { + stay: track("music/stay-gxscc.raw", { loop: true }), + stay45: track("music/stay-raxlen-45s.raw", { loop: true }), + }, +}); diff --git a/edge/package.json b/edge/package.json new file mode 100644 index 0000000..45c6f99 --- /dev/null +++ b/edge/package.json @@ -0,0 +1,24 @@ +{ + "name": "@pocketjs/edge", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "GBA interactive action-drama DSL: cine/saga cinematic vocabulary (Mode-0 parallax, raster FX, typewriter captions, walkable worlds) plus a side-scrolling action core (run/jump/shoot, enemy AI, bullet-time) and DirectSound PCM insert songs — one TypeScript source, one flashcart-ready ROM.", + "exports": { + ".": "./dsl/index.ts", + "./compiler": "./compiler/index.ts", + "./spec": "./spec/edge.ts" + }, + "scripts": { + "gen": "bun spec/gen-c.ts", + "build": "bun compiler/cli.ts build game/see-you-on-the-moon.ts --out dist/see-you-on-the-moon.gba --title MOONSMILE", + "smoke": "bun test/gen-placeholder-art.ts && bun compiler/cli.ts build test/smoke-film.ts --out dist/smoke.gba --title EDGESMOKE", + "test:engine": "bun test/engine-e2e.ts", + "art": "bun pixellab/generate.ts", + "test": "bun test/e2e.ts", + "play": "bun play.ts" + }, + "devDependencies": { + "typescript": "^5" + } +} diff --git a/edge/pixellab/actionsheets.ts b/edge/pixellab/actionsheets.ts new file mode 100644 index 0000000..8ff93b1 --- /dev/null +++ b/edge/pixellab/actionsheets.ts @@ -0,0 +1,172 @@ +// edge/pixellab/actionsheets.ts — assemble side-view ACTION sheets from the +// generated stills. +// bun pixellab/actionsheets.ts [--force] +// +// player act_david.png 256x32: idle, run x4 (animate-with-text), jump, shoot, hurt +// enemies en_.png 128x32: idle, walk0, walk1, attack +// humanoids get real walk frames via animate; drone/turret get a +// procedural hover-bob (animating a tripod reads as mush). +// boss boss_smasher.png 256x64: idle, walk x2 (animate), attack +// +// Everything is cached: existing sheets are skipped without --force. + +import { apiKey } from "./client.ts"; +import { decodePng, encodePng } from "../compiler/png.ts"; + +const ART = new URL("../game/art/", import.meta.url).pathname; + +function nn(rgba: Uint8Array, w: number, h: number, k: number): Uint8Array { + const out = new Uint8Array(w * k * h * k * 4); + for (let y = 0; y < h * k; y++) + for (let x = 0; x < w * k; x++) { + const si = (Math.floor(y / k) * w + Math.floor(x / k)) * 4; + out.set(rgba.subarray(si, si + 4), (y * w * k + x) * 4); + } + return out; +} +function shrink2(rgba: Uint8Array, w: number, h: number): Uint8Array { + const out = new Uint8Array((w / 2) * (h / 2) * 4); + for (let y = 0; y < h / 2; y++) + for (let x = 0; x < w / 2; x++) + out.set(rgba.subarray((y * 2 * w + x * 2) * 4, (y * 2 * w + x * 2) * 4 + 4), (y * (w / 2) + x) * 4); + return out; +} + +async function load(name: string, size: number): Promise { + const d = decodePng(new Uint8Array(await Bun.file(`${ART}${name}.png`).arrayBuffer())); + if (d.width !== size || d.height !== size) throw new Error(`${name}: expected ${size}x${size}, got ${d.width}x${d.height}`); + return d.rgba; +} + +function sheet(frames: Uint8Array[], size: number): Uint8Array { + const w = size * frames.length; + const out = new Uint8Array(w * size * 4); + frames.forEach((f, i) => { + for (let y = 0; y < size; y++) + for (let x = 0; x < size; x++) + out.set(f.subarray((y * size + x) * 4, (y * size + x) * 4 + 4), (y * w + i * size + x) * 4); + }); + return encodePng(out, w, size); +} + +/** shift a frame vertically (positive = down), transparent fill */ +function bob(rgba: Uint8Array, size: number, dy: number): Uint8Array { + const out = new Uint8Array(rgba.length); + for (let y = 0; y < size; y++) { + const sy = y - dy; + if (sy < 0 || sy >= size) continue; + out.set(rgba.subarray(sy * size * 4, (sy + 1) * size * 4), y * size * 4); + } + return out; +} + +async function animate( + still: Uint8Array, + size: number, + look: string, + action: string, + nFrames = 4, +): Promise { + // animate-with-text wants >= 64px canvases; 32px stills take a 2x round trip + const k = size < 64 ? 2 : 1; + const big = k === 2 ? encodePng(nn(still, size, size, 2), size * 2, size * 2) : encodePng(still, size, size); + let lastErr = ""; + for (let attempt = 0; attempt < 4; attempt++) { + const res = await fetch("https://api.pixellab.ai/v1/animate-with-text", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey()}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + image_size: { width: size * k, height: size * k }, + description: look, + action, + reference_image: { type: "base64", base64: Buffer.from(big).toString("base64") }, + view: "side", + direction: "east", + n_frames: nFrames, + }), + }); + if (res.ok) { + const body = (await res.json()) as { images?: { base64?: string }[] }; + return (body.images ?? []).map((img) => { + const d = decodePng(new Uint8Array(Buffer.from(img.base64!, "base64"))); + return k === 2 ? shrink2(d.rgba, d.width, d.height) : d.rgba; + }); + } + lastErr = `${res.status} ${await res.text()}`; + if (res.status === 422 || res.status === 401) break; + await new Promise((r) => setTimeout(r, 2000 * (attempt + 1))); + } + throw new Error(`animate-with-text(${action}): ${lastErr}`); +} + +const force = process.argv.includes("--force"); +const exists = async (n: string): Promise => !force && (await Bun.file(ART + n).exists()); + +import { DAVID, THUG, GUARD, COP, SMASHER } from "./generate.ts"; + +// --- player: idle, run x4, jump, shoot, hurt -------------------------------------- +if (await exists("act_david.png")) { + console.log("skip act_david (cached)"); +} else { + const idle = await load("act_david_idle", 32); + process.stdout.write(" animate david run... "); + const run = await animate(idle, 32, DAVID, "run", 4); + if (run.length < 4) throw new Error(`david run: only ${run.length} frames`); + console.log("ok"); + const jump = await load("act_david_jump", 32); + const shoot = await load("act_david_shoot", 32); + const hurt = await load("act_david_hurt", 32); + await Bun.write(ART + "act_david.png", sheet([idle, ...run.slice(0, 4), jump, shoot, hurt], 32)); + console.log("wrote act_david.png (8 frames)"); +} + +// --- humanoid enemies: idle, walk x2 (animated), attack --------------------------- +for (const [who, look] of [ + ["thug", THUG], + ["guard", GUARD], + ["cop", COP], +] as const) { + if (await exists(`en_${who}.png`)) { + console.log(`skip en_${who} (cached)`); + continue; + } + const idle = await load(`en_${who}_idle`, 32); + const attack = await load(`en_${who}_attack`, 32); + process.stdout.write(` animate ${who} walk... `); + const walk = await animate(idle, 32, look, "walk", 4); + console.log("ok"); + await Bun.write(ART + `en_${who}.png`, sheet([idle, walk[1] ?? bob(idle, 32, 1), walk[2] ?? bob(idle, 32, -1), attack], 32)); + console.log(`wrote en_${who}.png (4 frames)`); +} + +// --- drone + turret: procedural hover-bob ------------------------------------------ +for (const who of ["drone", "turret"] as const) { + if (await exists(`en_${who}.png`)) { + console.log(`skip en_${who} (cached)`); + continue; + } + const idle = await load(`en_${who}_idle`, 32); + const attack = await load(`en_${who}_attack`, 32); + const f1 = who === "drone" ? bob(idle, 32, -1) : idle; + const f2 = who === "drone" ? bob(idle, 32, 1) : idle; + await Bun.write(ART + `en_${who}.png`, sheet([idle, f1, f2, attack], 32)); + console.log(`wrote en_${who}.png (4 frames)`); +} + +// --- boss --------------------------------------------------------------------------- +if (await exists("boss_smasher.png")) { + console.log("skip boss_smasher (cached)"); +} else { + const idle = await load("boss_smasher_idle", 64); + const attack = await load("boss_smasher_attack", 64); + process.stdout.write(" animate smasher walk... "); + const walk = await animate(idle, 64, SMASHER, "walk", 4); + console.log("ok"); + await Bun.write( + ART + "boss_smasher.png", + sheet([idle, walk[1] ?? bob(idle, 64, 1), walk[2] ?? bob(idle, 64, -1), attack], 64), + ); + console.log("wrote boss_smasher.png (4 frames)"); +} + +console.log("action sheets done."); diff --git a/edge/pixellab/cleanup.ts b/edge/pixellab/cleanup.ts new file mode 100644 index 0000000..5f052dd --- /dev/null +++ b/edge/pixellab/cleanup.ts @@ -0,0 +1,178 @@ +// edge/pixellab/cleanup.ts — deterministic post-process for animate-with-text +// frames already assembled into sheets. No API calls; safe to re-run. +// +// bun pixellab/cleanup.ts +// +// animate-with-text drifts: hue shifts (a yellow jacket comes back teal) and +// motion-streak halos, sometimes attached to the figure. Treatment: +// - animated frames get the largest-4-connected-component filter, then a +// LUMA REMAP: every pixel is replaced by the still-palette color of the +// nearest brightness, so whole frames are forced into the character's +// own colors (shape survives, hue drift cannot), +// - frames listed in REPLACE are beyond saving (halo fused to the body): +// they are rebuilt as ±1px step-bobs of the row's generated still, +// - stills get a crisp binary-alpha pass. + +import { decodePng, encodePng } from "../compiler/png.ts"; + +const ART = new URL("../game/art/", import.meta.url).pathname; + +interface Sheet { + rgba: Uint8Array; + w: number; + h: number; + size: number; // frame is size x size + n: number; +} + +async function loadSheet(name: string, size: number): Promise { + const d = decodePng(new Uint8Array(await Bun.file(`${ART}${name}.png`).arrayBuffer())); + if (d.height !== size || d.width % size !== 0) throw new Error(`${name}: bad sheet ${d.width}x${d.height}`); + return { rgba: d.rgba, w: d.width, h: d.height, size, n: d.width / size }; +} + +function framePixels(s: Sheet, f: number): Uint8Array { + const out = new Uint8Array(s.size * s.size * 4); + for (let y = 0; y < s.size; y++) + for (let x = 0; x < s.size; x++) + out.set(s.rgba.subarray((y * s.w + f * s.size + x) * 4, (y * s.w + f * s.size + x) * 4 + 4), (y * s.size + x) * 4); + return out; +} + +function writeFrame(s: Sheet, f: number, px: Uint8Array): void { + for (let y = 0; y < s.size; y++) + for (let x = 0; x < s.size; x++) + s.rgba.set(px.subarray((y * s.size + x) * 4, (y * s.size + x) * 4 + 4), (y * s.w + f * s.size + x) * 4); +} + +/** luma-sorted unique palette (deduped by luma bucket) from still frames */ +function lumaPalette(frames: Uint8Array[]): number[][] { + const byLuma = new Map(); + for (const px of frames) + for (let i = 0; i < px.length; i += 4) { + if (px[i + 3] < 128) continue; + const luma = (px[i] * 5 + px[i + 1] * 9 + px[i + 2] * 2) >> 4; + if (!byLuma.has(luma)) byLuma.set(luma, [px[i], px[i + 1], px[i + 2], luma]); + } + return [...byLuma.values()].sort((a, b) => a[3] - b[3]); +} + +function largestComponent(px: Uint8Array, size: number): Uint8Array { + const solid = new Uint8Array(size * size); + for (let i = 0; i < size * size; i++) solid[i] = px[i * 4 + 3] >= 128 ? 1 : 0; + const comp = new Int32Array(size * size).fill(-1); + const sizes: number[] = []; + const stack: number[] = []; + for (let i = 0; i < size * size; i++) { + if (!solid[i] || comp[i] >= 0) continue; + const id = sizes.length; + let count = 0; + stack.push(i); + comp[i] = id; + while (stack.length) { + const c = stack.pop()!; + count++; + const cx = c % size, cy = (c / size) | 0; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) { + const nx = cx + dx, ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= size || ny >= size) continue; + const nn = ny * size + nx; + if (solid[nn] && comp[nn] < 0) { + comp[nn] = id; + stack.push(nn); + } + } + } + sizes.push(count); + } + let best = 0; + for (let i = 1; i < sizes.length; i++) if (sizes[i] > sizes[best]) best = i; + const keep = new Uint8Array(size * size); + for (let i = 0; i < size * size; i++) keep[i] = solid[i] && comp[i] === best ? 1 : 0; + return keep; +} + +function lumaRemap(px: Uint8Array, size: number, pal: number[][]): Uint8Array { + const keep = largestComponent(px, size); + const out = new Uint8Array(px.length); + for (let i = 0; i < size * size; i++) { + if (!keep[i]) continue; + const luma = (px[i * 4] * 5 + px[i * 4 + 1] * 9 + px[i * 4 + 2] * 2) >> 4; + // binary search-ish: nearest luma in sorted palette + let lo = 0, hi = pal.length - 1; + while (lo < hi) { + const mid = (lo + hi) >> 1; + if (pal[mid][3] < luma) lo = mid + 1; + else hi = mid; + } + const cand = lo > 0 && Math.abs(pal[lo - 1][3] - luma) < Math.abs(pal[lo][3] - luma) ? pal[lo - 1] : pal[lo]; + out[i * 4] = cand[0]; + out[i * 4 + 1] = cand[1]; + out[i * 4 + 2] = cand[2]; + out[i * 4 + 3] = 255; + } + return out; +} + +/** vertical step-bob of a still (feet-safe: bottom row preserved) */ +function bobFrame(still: Uint8Array, size: number, dy: number): Uint8Array { + const out = new Uint8Array(still.length); + for (let y = 0; y < size; y++) { + const sy = Math.min(size - 1, Math.max(0, y - dy)); + out.set(still.subarray(sy * size * 4, (sy * size + size) * 4), y * size * 4); + } + return out; +} + +interface CleanSpec { + name: string; + size: number; + stills: number[]; + anims: number[]; + /** frame -> [stillFrame, dy]: rebuild as a bob of that still */ + replace?: Record; +} + +async function cleanSheet(spec: CleanSpec): Promise { + const s = await loadSheet(spec.name, spec.size); + const stills = spec.stills.map((f) => framePixels(s, f)); + const pal = lumaPalette(stills); + for (const f of spec.anims) { + const rep = spec.replace?.[f]; + if (rep) { + writeFrame(s, f, bobFrame(framePixels(s, rep[0]), spec.size, rep[1])); + } else { + writeFrame(s, f, lumaRemap(framePixels(s, f), spec.size, pal)); + } + } + for (const f of spec.stills) { + const px = framePixels(s, f); + for (let i = 3; i < px.length; i += 4) px[i] = px[i] >= 128 ? 255 : 0; + writeFrame(s, f, px); + } + await Bun.write(`${ART}${spec.name}.png`, encodePng(s.rgba, s.w, s.h)); + console.log(`cleaned ${spec.name} (${spec.anims.length} frames, ${pal.length} luma colors)`); +} + +await cleanSheet({ name: "act_david", size: 32, stills: [0, 5, 6, 7], anims: [1, 2, 3, 4] }); +await cleanSheet({ name: "en_thug", size: 32, stills: [0, 3], anims: [1, 2] }); +await cleanSheet({ name: "en_guard", size: 32, stills: [0, 3], anims: [1, 2] }); +await cleanSheet({ name: "en_cop", size: 32, stills: [0, 3], anims: [1, 2] }); +await cleanSheet({ name: "boss_smasher", size: 64, stills: [0, 3], anims: [1, 2] }); +// walkers: rows DOWN,UP,SIDE x4; frames 0/4/8 are the generated stills. +// david's UP row and lucy's DOWN row came back with fused halos — step-bobs. +await cleanSheet({ + name: "wd_david", + size: 32, + stills: [0, 4, 8], + anims: [1, 2, 3, 5, 6, 7, 9, 10, 11], + replace: { 5: [4, -1], 6: [4, 0], 7: [4, 1] }, +}); +await cleanSheet({ + name: "wd_lucy", + size: 32, + stills: [0, 4, 8], + anims: [1, 2, 3, 5, 6, 7, 9, 10, 11], + replace: { 1: [0, -1], 2: [0, 0], 3: [0, 1] }, +}); +console.log("done."); diff --git a/edge/pixellab/client.ts b/edge/pixellab/client.ts new file mode 100644 index 0000000..616c39c --- /dev/null +++ b/edge/pixellab/client.ts @@ -0,0 +1,77 @@ +// edge/pixellab/client.ts — thin typed client for the PixelLab API +// (https://api.pixellab.ai/v1). Bearer key comes from the repo-root .env +// (PIXELLAB_API_KEY). Endpoints used: generate-image-pixflux (up to 400x400), +// with retry/backoff; responses carry base64 PNGs. + +const API = "https://api.pixellab.ai/v1"; + +let cachedKey: string | null = null; + +export function apiKey(): string { + if (cachedKey) return cachedKey; + const envPath = new URL("../../.env", import.meta.url).pathname; + const text = require("node:fs").readFileSync(envPath, "utf8") as string; + const m = text.match(/^PIXELLAB_API_KEY=(.+)$/m); + if (!m) throw new Error("PIXELLAB_API_KEY not found in repo .env"); + cachedKey = m[1].trim(); + return cachedKey; +} + +export interface PixfluxOpts { + description: string; + width: number; + height: number; + negative?: string; + noBackground?: boolean; + outline?: "single color black outline" | "single color outline" | "selective outline" | "lineless"; + shading?: "flat shading" | "basic shading" | "medium shading" | "detailed shading" | "highly detailed shading"; + detail?: "low detail" | "medium detail" | "highly detailed"; + view?: "side" | "low top-down" | "high top-down"; + direction?: string; + textGuidance?: number; + seed?: number; + /** force palette: PNG bytes whose colors constrain the output */ + colorImage?: Uint8Array; +} + +async function post(path: string, body: unknown): Promise> { + let lastErr = ""; + for (let attempt = 0; attempt < 4; attempt++) { + const res = await fetch(API + path, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey()}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (res.ok) return (await res.json()) as Record; + lastErr = `${res.status} ${await res.text()}`; + if (res.status === 422 || res.status === 401) break; // no point retrying + await new Promise((r) => setTimeout(r, 2000 * (attempt + 1))); + } + throw new Error(`pixellab ${path}: ${lastErr}`); +} + +export async function pixflux(opts: PixfluxOpts): Promise { + const body: Record = { + description: opts.description, + image_size: { width: opts.width, height: opts.height }, + no_background: opts.noBackground ?? false, + text_guidance_scale: opts.textGuidance ?? 8, + }; + if (opts.negative) body.negative_description = opts.negative; + if (opts.outline) body.outline = opts.outline; + if (opts.shading) body.shading = opts.shading; + if (opts.detail) body.detail = opts.detail; + if (opts.view) body.view = opts.view; + if (opts.direction) body.direction = opts.direction; + if (opts.seed !== undefined) body.seed = opts.seed; + if (opts.colorImage) body.color_image = { type: "base64", base64: Buffer.from(opts.colorImage).toString("base64") }; + const res = await post("/generate-image-pixflux", body); + const img = res.image as { base64: string } | undefined; + if (!img?.base64) throw new Error("pixellab: no image in response"); + return new Uint8Array(Buffer.from(img.base64, "base64")); +} + +export async function balance(): Promise { + const res = await fetch(API + "/balance", { headers: { Authorization: `Bearer ${apiKey()}` } }); + return await res.text(); +} diff --git a/edge/pixellab/generate.ts b/edge/pixellab/generate.ts new file mode 100644 index 0000000..c05ea39 --- /dev/null +++ b/edge/pixellab/generate.ts @@ -0,0 +1,393 @@ +// edge/pixellab/generate.ts — generate every game asset through PixelLab and +// cache it under game/art/ (committed, so builds never re-bill). +// bun pixellab/generate.ts [--force] [--only name[,name]] +// +// Then: +// bun pixellab/walkers.ts top-down walker sheets (world scenes) +// bun pixellab/actionsheets.ts side-view action sheets (player/enemies/boss) +// +// Prompts are descriptive, not trademarked: no logos, no franchise names, no +// trade dress. This is an unaffiliated fan tribute; the cast is described in +// plain visual language. + +import { pixflux, balance, type PixfluxOpts } from "./client.ts"; + +const OUT = new URL("../game/art/", import.meta.url).pathname; + +interface Spec extends Omit { + name: string; + w: number; + h: number; +} + +const MAP_STYLE = + "seen directly from above like a Game Boy RPG town map, clean 16x16 tile alignment, top-down 2D RPG interior map"; +const STAGE_STYLE = + "side view 2D action platformer stage, flat ground along the bottom, detailed pixel art game background, neon-soaked cyberpunk megacity at night"; +const SPRITE = "tiny full body pixel art sprite, head to toe visible, Game Boy Advance sprite"; + +// --- the cast, in plain visual language (per the research dossier) ---------------- +export const DAVID = + "lean latino teen, dark brown quiff hair with shaved sides, open yellow paramedic bomber jacket with green emblem, bare chest with gold chain necklaces, gray pants, white sneakers"; +export const LUCY = + "pale slender young woman, white asymmetric bob haircut with pastel rainbow tips, cropped white jacket over black high-collar bodysuit with red accents, white shorts, gray stockings, black boots"; +export const REBECCA = + "very short young woman with stark white skin, long turquoise-green twin tails, oversized black high-collar jacket with green accents, red and blue chunky robot arms, huge green and pink shotgun"; +export const MAINE = + "huge towering muscular black man with short blond hair, gunmetal chrome cyborg arms, olive tactical vest"; +export const DORIO = + "very tall muscular woman with short blonde hair, open dark jacket, black shorts, bare chrome metal legs"; +export const PILAR = + "tall lanky man with a black mohawk, golden metal robot hands, goggle rig framing his jaw, sleeveless black vest"; +export const FALCO = + "older man with semi-long dark brown hair parted in the middle, thick curled mustache, brown western waistcoat over white shirt, one silver chrome arm"; +export const KIWI = + "very tall slender woman, light blonde bob haircut, glossy red mask plate covering her lower face, long red coat, cyan web tattoos"; +export const GLORIA = "tired woman in her forties, vivid red hair tied back, orange and white paramedic uniform"; +export const THUG = "street gang punk, tall red mohawk, spiked leather jacket, one chrome cyber arm, knuckle wraps"; +export const GUARD = "corporate security soldier, dark navy armored uniform, mirrored visor helmet, compact rifle"; +export const COP = "heavily armored tactical trooper, matte black exo armor, riot helmet with red lenses"; +export const DRONE = "small hovering security drone, twin ducted rotors, single red sensor eye, gunmetal shell"; +export const TURRET = "compact automated sentry gun turret on a squat tripod, twin barrels, amber sensor"; +export const SMASHER = + "towering heavy full-metal chrome cyborg, skull-like silver faceplate, single red eye, enormous shoulders, gunmetal and black armor plating, arm cannon"; + +export const SPECS: Spec[] = [ + // --- cine backgrounds (240x160) ---------------------------------------------- + { + name: "bg_apartment", + w: 240, + h: 160, + description: + "small cramped one-room megacity apartment at night, side view interior, bunk bed, tiny kitchen corner, laundry on a line, one big window filling the back wall with dense neon city towers and holographic ads outside, warm lamp inside, detailed pixel art game background", + negative: "people, person, text, logo, watermark", + detail: "highly detailed", + shading: "detailed shading", + seed: 71001, + }, + { + name: "bg_rooftop", + w: 240, + h: 160, + description: + "rooftop of a megacity skyscraper at night seen from the roof surface, low concrete ledge in the foreground, vast glittering neon city sprawling far below, huge full moon dominating the sky, thin clouds, cold blue night with pink neon glow on the horizon, detailed pixel art game background, wide shot", + negative: "people, person, text, logo, watermark", + detail: "highly detailed", + shading: "detailed shading", + seed: 71002, + }, + { + name: "bg_moon", + w: 240, + h: 160, + description: + "gray lunar surface with fine dust and small craters, black star-filled sky, the blue Earth hanging large and luminous above the horizon, a small domed lunar base far in the distance, quiet and vast, detailed pixel art game background", + negative: "people, person, astronaut, text, logo, watermark", + detail: "highly detailed", + shading: "detailed shading", + seed: 71003, + }, + { + name: "bg_clinic", + w: 240, + h: 160, + description: + "back-alley cybernetics clinic interior at night, side view, reclined operating chair under a ring of surgical lights, racks of chrome body parts and cables on the walls, monitors with green readouts, grimy floor, moody teal and magenta lighting, detailed pixel art game background", + negative: "people, person, text, logo, watermark", + detail: "highly detailed", + shading: "detailed shading", + seed: 71004, + }, + // --- action stages (512x160 / 384x160, side view) ------------------------------ + { + name: "stage_alley", + w: 384, + h: 160, + description: `${STAGE_STYLE}, narrow back alley between towering buildings, wet asphalt reflecting pink and cyan neon signs, dumpsters, tangled cables overhead, steam vents, fire escapes, distant towers glowing between the buildings`, + negative: "people, person, text, letters, logo, watermark", + detail: "highly detailed", + shading: "detailed shading", + seed: 71010, + }, + { + name: "stage_warehouse", + w: 384, + h: 160, + description: `${STAGE_STYLE}, corporate cargo warehouse interior at night, stacked shipping containers and crates, yellow gantry crane overhead, hazard stripes on the concrete floor, cold blue industrial lights, chain-link fencing, loading dock doors`, + negative: "people, person, text, letters, logo, watermark", + detail: "highly detailed", + shading: "detailed shading", + seed: 71011, + }, + { + name: "stage_street", + w: 384, + h: 160, + description: `${STAGE_STYLE}, wide downtown avenue at night, holographic billboards and neon storefronts, parked futuristic cars, overturned barricades, palm trees under sodium lights, elevated rail line crossing above, smoke in the distance`, + negative: "people, person, text, letters, logo, watermark", + detail: "highly detailed", + shading: "detailed shading", + seed: 71012, + }, + { + name: "stage_lab", + w: 384, + h: 160, + description: `${STAGE_STYLE}, sterile corporate laboratory corridor, white and gray panels, glass walls into server rooms glowing blue, robotic arms idle at workstations, red warning lights strobing, cable trays along the ceiling, polished floor`, + negative: "people, person, text, letters, logo, watermark", + detail: "highly detailed", + shading: "detailed shading", + seed: 71013, + }, + { + name: "stage_pad", + w: 384, + h: 160, + description: `${STAGE_STYLE}, flat rooftop landing pad floor running continuously along the entire bottom edge of the image, helipad deck plating with painted markings as the walkable ground, low guard rails behind, antenna masts and vent stacks, distant skyscraper tops and thin clouds far below the railing, cold dawn sky in orange and steel blue`, + negative: "people, person, aircraft, pit, gap, chasm, text, letters, logo, watermark", + detail: "highly detailed", + shading: "detailed shading", + seed: 71114, + }, + // --- world maps (top-down) ------------------------------------------------------- + { + name: "map_train", + w: 320, + h: 160, + description: `top-down 2D RPG map of a futuristic metro train car interior, ${MAP_STYLE}, sleek silver car walls forming the border, rows of paired seats along both sides leaving a walkable center aisle, sliding doors at both ends, luggage racks, small floor lights, teal and gray palette`, + negative: "people, person, text, side view, perspective, isometric", + view: "high top-down", + detail: "highly detailed", + shading: "basic shading", + seed: 71020, + }, + { + name: "map_hideout", + w: 320, + h: 240, + description: `top-down 2D RPG map of a dive bar back room turned mercenary hideout, ${MAP_STYLE}, dark brick walls forming the border, a long bar counter with stools along the top wall, a pool table in the middle, a weapons workbench, a torn couch and low table, neon sign glow spilling across the floor, crates in a corner`, + negative: "people, person, text, side view, perspective, isometric", + view: "high top-down", + detail: "highly detailed", + shading: "basic shading", + seed: 71021, + }, + // --- top-down walker stills (32x32, assembled by walkers.ts) --------------------- + ...( + [ + ["david", DAVID, 71030, ["s", "n", "e"]], + ["lucy", LUCY, 71033, ["s", "n", "e"]], + ["maine", MAINE, 71036, ["s", "n", "e"]], + ["rebecca", REBECCA, 71039, ["s", "n", "e"]], + ["falco", FALCO, 71042, ["s"]], + ["kiwi", KIWI, 71043, ["s"]], + ["gloria", GLORIA, 71044, ["s"]], + ["dorio", DORIO, 71045, ["s"]], + ["pilar", PILAR, 71046, ["s"]], + ] as [string, string, number, string[]][] + ).flatMap(([who, look, seed, dirs]) => + dirs.map((sfx, i) => ({ + name: `walk_${who}_${sfx}`, + w: 32, + h: 32, + description: `${SPRITE} of ${look}, standing`, + negative: "portrait, bust, cropped, text", + noBackground: true, + outline: "single color black outline" as const, + shading: "basic shading" as const, + view: "low top-down" as const, + direction: (sfx === "s" ? "south" : sfx === "n" ? "north" : "east") as PixfluxOpts["direction"], + seed: seed + i, + })), + ), + // --- side-view action stills (assembled by actionsheets.ts) ---------------------- + // player poses (32x32): idle / jump / shoot / hurt — run frames come from + // animate-with-text off the idle still. + { + name: "act_david_idle", + w: 32, + h: 32, + description: `${SPRITE} of ${DAVID}, side view facing right, standing relaxed holding a compact pistol low`, + negative: "portrait, bust, cropped, text, front view", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + direction: "east", + seed: 71050, + }, + { + name: "act_david_jump", + w: 32, + h: 32, + description: `${SPRITE} of ${DAVID}, side view facing right, mid-air jump with knees tucked, dynamic`, + negative: "portrait, bust, cropped, text, front view, standing", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + direction: "east", + seed: 71051, + }, + { + name: "act_david_shoot", + w: 32, + h: 32, + description: `${SPRITE} of ${DAVID}, side view facing right, two-handed pistol aimed straight forward, muzzle flash`, + negative: "portrait, bust, cropped, text, front view", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + direction: "east", + seed: 71052, + }, + { + name: "act_david_hurt", + w: 32, + h: 32, + description: `${SPRITE} of ${DAVID}, side view facing right, flinching backward in pain, arms raised`, + negative: "portrait, bust, cropped, text, front view", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + direction: "east", + seed: 71053, + }, + // enemies: idle + attack stills per type (32x32); walk frames animated + ...( + [ + ["thug", THUG, "lunging forward throwing a punch with the chrome arm", 71060], + ["guard", GUARD, "kneeling and firing the rifle, small muzzle flash", 71062], + ["cop", COP, "braced and firing a heavy rifle, small muzzle flash", 71064], + ["drone", DRONE, "tilted forward, red eye flaring, firing a shot downward", 71066], + ["turret", TURRET, "barrels recoiling with a small muzzle flash", 71068], + ] as [string, string, string, number][] + ).flatMap(([who, look, attack, seed]) => [ + { + name: `en_${who}_idle`, + w: 32, + h: 32, + description: `${SPRITE} of ${look}, side view facing right, standing`, + negative: "portrait, bust, cropped, text, front view", + noBackground: true, + outline: "single color black outline" as const, + shading: "basic shading" as const, + direction: "east" as const, + seed, + }, + { + name: `en_${who}_attack`, + w: 32, + h: 32, + description: `${SPRITE} of ${look}, side view facing right, ${attack}`, + negative: "portrait, bust, cropped, text, front view", + noBackground: true, + outline: "single color black outline" as const, + shading: "basic shading" as const, + direction: "east" as const, + seed: seed + 1, + }, + ]), + // boss (64x64): idle + attack stills; walk animated + { + name: "boss_smasher_idle", + w: 64, + h: 64, + description: `${SPRITE} of ${SMASHER}, side view facing right, standing menacingly, full body head to toe`, + negative: "portrait, bust, cropped, text, front view", + noBackground: true, + outline: "single color black outline", + shading: "detailed shading", + direction: "east", + seed: 71070, + }, + { + name: "boss_smasher_attack", + w: 64, + h: 64, + description: `${SPRITE} of ${SMASHER}, side view facing right, arm cannon raised and firing, muzzle flash, full body head to toe`, + negative: "portrait, bust, cropped, text, front view", + noBackground: true, + outline: "single color black outline", + shading: "detailed shading", + direction: "east", + seed: 71071, + }, + // cutscene sprites + { + name: "spr_duo_ledge", + w: 64, + h: 32, + description: + "tiny pixel art sprite of two people sitting side by side on a concrete rooftop ledge seen from behind, on the left a lean young man with undercut dark hair and yellow-black jacket, on the right a slender woman with silver-white bob hair and white jacket, feet dangling, night", + negative: "portrait, bust, cropped, text, front view, faces", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + seed: 71080, + }, + { + name: "spr_lucy_suit", + w: 32, + h: 32, + description: `${SPRITE} of a slender woman in a sleek white and red spacesuit without helmet, silver-white bob hair with pastel tips, walking, side view facing right`, + negative: "portrait, bust, cropped, text", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + direction: "east", + seed: 71081, + }, + { + name: "spr_gloria_fallen", + w: 32, + h: 32, + description: + "tiny pixel art sprite of a woman in an orange and white paramedic uniform lying injured on the ground, side view, full body", + negative: "portrait, bust, cropped, text, standing", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + seed: 71082, + }, + { + name: "spr_cyberskel", + w: 32, + h: 32, + description: `${SPRITE} of a lean young man with undercut dark hair fused into a skeletal chrome exoskeleton frame, glowing cyan joints, side view facing right, standing`, + negative: "portrait, bust, cropped, text, front view", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + direction: "east", + seed: 71083, + }, +]; + +if (import.meta.main) { + const force = process.argv.includes("--force"); + const onlyArg = process.argv.indexOf("--only"); + const only = onlyArg >= 0 ? new Set(process.argv[onlyArg + 1].split(",")) : null; + + console.log("pixellab balance:", await balance()); + const manifest: Record = {}; + const manifestPath = OUT + "manifest.json"; + try { + Object.assign(manifest, JSON.parse(await Bun.file(manifestPath).text())); + } catch {} + + for (const spec of SPECS) { + const { name, w, h, ...opts } = spec; + if (only && !only.has(name)) continue; + const out = OUT + name + ".png"; + if (!force && (await Bun.file(out).exists())) { + console.log(` skip ${name} (cached)`); + continue; + } + process.stdout.write(` gen ${name} ${w}x${h}... `); + const png = await pixflux({ ...opts, width: w, height: h }); + await Bun.write(out, png); + manifest[name] = { prompt: spec.description, seed: spec.seed, w, h }; + await Bun.write(manifestPath, JSON.stringify(manifest, null, 2)); + console.log(`ok (${png.length}B)`); + } + console.log("done. balance:", await balance()); +} diff --git a/edge/pixellab/walkers.ts b/edge/pixellab/walkers.ts new file mode 100644 index 0000000..766b685 --- /dev/null +++ b/edge/pixellab/walkers.ts @@ -0,0 +1,139 @@ +// edge/pixellab/walkers.ts — assemble top-down walker sheets from the stills. +// bun pixellab/walkers.ts [--force] +// +// david, lucy: real 4-frame walk cycles per direction via /animate-with-text +// (64x64 minimum -> 2x nearest-neighbor round trip) -> wd_.png +// 384x32, rows DOWN,UP,SIDE x 4 frames (walkFpd 4). Frame 0 of each +// row is the standing still. +// maine, rebecca: 3-frame sheets [south, north, east] (walkFpd 1) +// falco, kiwi, gloria, dorio, pilar: south-only stills tripled (walkFpd 1) + +import { apiKey } from "./client.ts"; +import { decodePng, encodePng } from "../compiler/png.ts"; +import { DAVID, LUCY } from "./generate.ts"; + +const ART = new URL("../game/art/", import.meta.url).pathname; +const FRAME = 32; + +function nn(rgba: Uint8Array, w: number, h: number, k: number): Uint8Array { + const out = new Uint8Array(w * k * h * k * 4); + for (let y = 0; y < h * k; y++) + for (let x = 0; x < w * k; x++) { + const si = (Math.floor(y / k) * w + Math.floor(x / k)) * 4; + out.set(rgba.subarray(si, si + 4), (y * w * k + x) * 4); + } + return out; +} +function shrink2(rgba: Uint8Array, w: number, h: number): Uint8Array { + const out = new Uint8Array((w / 2) * (h / 2) * 4); + for (let y = 0; y < h / 2; y++) + for (let x = 0; x < w / 2; x++) { + out.set(rgba.subarray((y * 2 * w + x * 2) * 4, (y * 2 * w + x * 2) * 4 + 4), (y * (w / 2) + x) * 4); + } + return out; +} + +async function loadFrame(path: string): Promise { + const d = decodePng(new Uint8Array(await Bun.file(path).arrayBuffer())); + if (d.width !== FRAME || d.height !== FRAME) throw new Error(`${path}: expected ${FRAME}x${FRAME}`); + return d.rgba; +} + +function sheet(frames: Uint8Array[]): Uint8Array { + const w = FRAME * frames.length; + const out = new Uint8Array(w * FRAME * 4); + frames.forEach((f, i) => { + for (let y = 0; y < FRAME; y++) + for (let x = 0; x < FRAME; x++) { + out.set(f.subarray((y * FRAME + x) * 4, (y * FRAME + x) * 4 + 4), (y * w + i * FRAME + x) * 4); + } + }); + return encodePng(out, w, FRAME); +} + +async function animate(still: Uint8Array, look: string, direction: string): Promise { + const big = encodePng(nn(still, FRAME, FRAME, 2), FRAME * 2, FRAME * 2); + let lastErr = ""; + for (let attempt = 0; attempt < 4; attempt++) { + const res = await fetch("https://api.pixellab.ai/v1/animate-with-text", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey()}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + image_size: { width: FRAME * 2, height: FRAME * 2 }, + description: look, + action: "walk", + reference_image: { type: "base64", base64: Buffer.from(big).toString("base64") }, + view: "low top-down", + direction, + n_frames: 4, + }), + }); + if (res.ok) { + const body = (await res.json()) as { images?: { base64?: string }[] }; + return (body.images ?? []).map((img) => { + const d = decodePng(new Uint8Array(Buffer.from(img.base64!, "base64"))); + return shrink2(d.rgba, d.width, d.height); + }); + } + lastErr = `${res.status} ${await res.text()}`; + if (res.status === 422 || res.status === 401) break; + await new Promise((r) => setTimeout(r, 2000 * (attempt + 1))); + } + throw new Error(`animate-with-text(${direction}): ${lastErr}`); +} + +const force = process.argv.includes("--force"); + +// leads: 4-frame cycles, standing still as frame 0 of each row +for (const [who, look] of [ + ["david", DAVID], + ["lucy", LUCY], +] as const) { + const out = `${ART}wd_${who}.png`; + if (!force && (await Bun.file(out).exists())) { + console.log(`skip wd_${who} (cached)`); + continue; + } + const rows: Uint8Array[] = []; + for (const [suffix, dir] of [ + ["s", "south"], + ["n", "north"], + ["e", "east"], + ] as const) { + const still = await loadFrame(`${ART}walk_${who}_${suffix}.png`); + process.stdout.write(` animate ${who} ${dir}... `); + const frames = await animate(still, look, dir); + if (frames.length < 3) throw new Error(`only ${frames.length} frames for ${dir}`); + // row = still + walk frames 2,3,4 (frame 1 is usually closest to the still) + rows.push(still, ...frames.slice(1, 4)); + console.log(`ok (${frames.length} frames)`); + } + await Bun.write(out, sheet(rows)); + console.log(`wrote wd_${who}.png (12 frames)`); +} + +// crew NPCs with real facing stills +for (const who of ["maine", "rebecca"]) { + const out = `${ART}wd_${who}.png`; + if (!force && (await Bun.file(out).exists())) { + console.log(`skip wd_${who} (cached)`); + continue; + } + const frames = await Promise.all( + (["s", "n", "e"] as const).map((sfx) => loadFrame(`${ART}walk_${who}_${sfx}.png`)), + ); + await Bun.write(out, sheet(frames)); + console.log(`wrote wd_${who}.png (3 frames)`); +} + +// south-only NPCs: the still tripled so face() is harmless +for (const who of ["falco", "kiwi", "gloria", "dorio", "pilar"]) { + const out = `${ART}wd_${who}.png`; + if (!force && (await Bun.file(out).exists())) { + console.log(`skip wd_${who} (cached)`); + continue; + } + const s = await loadFrame(`${ART}walk_${who}_s.png`); + await Bun.write(out, sheet([s, s, s])); + console.log(`wrote wd_${who}.png (3 frames)`); +} diff --git a/edge/play.ts b/edge/play.ts new file mode 100644 index 0000000..3a02b69 --- /dev/null +++ b/edge/play.ts @@ -0,0 +1,19 @@ +#!/usr/bin/env bun +// edge/play.ts — build SEE YOU ON THE MOON and open it in mGBA. +// bun play.ts [game.ts] +import { $ } from "bun"; +import { basename } from "node:path"; + +const here = new URL(".", import.meta.url).pathname; +const game = process.argv[2] ?? "game/see-you-on-the-moon.ts"; +const out = `dist/${basename(game).replace(/\.[^.]+$/, "")}.gba`; + +await $`bun ${here}compiler/cli.ts build ${here}${game} --out ${here}${out} --title MOONSMILE`; + +const prefix = (await $`brew --prefix mgba`.nothrow().quiet().text()).trim(); +const app = prefix ? `${prefix}/mGBA.app` : ""; +if (app && (await Bun.file(`${app}/Contents/Info.plist`).exists())) { + await $`open -n ${app} --args ${here}${out}`; +} else { + await $`mgba ${here}${out}`; +} diff --git a/edge/runtime/action.c b/edge/runtime/action.c new file mode 100644 index 0000000..cb9846f --- /dev/null +++ b/edge/runtime/action.c @@ -0,0 +1,623 @@ +/* edge/runtime/action.c — the side-scrolling run-and-gun core. + * + * OP_ACTION blocks in WAITING_ACTION on the current scene's EdgeStage. The + * player actor keeps living in its Spr slot (so cutscene ops can pose the same + * sprite before/after); this core drives that slot's x/y/frame directly and + * owns dedicated OAM ranges for enemies, bullets and afterimages. + * + * Controls: D-pad run, A jump, B shoot (auto-melee inside ACT_MELEE_RANGE), + * hold R = Sandevistan while the gauge lasts: the world updates 1 of + * C_ACT_SLOW_DIV frames, the player every frame, the BG palette swaps to the + * compiler's tinted copy and ghost poses trail the player. + * + * The stage never fails the film: at 0 HP the player respawns at the last + * gate passed (deaths_var++). Result: C_ACT_CLEARED, or C_ACT_BOSS_PHASE the + * moment the boss' HP crosses its scripted threshold. */ +#include "edge.h" + +Act act; + +#define SPAWN_AHEAD 268 /* activate spawns this close to the player */ +#define CONTACT_W 14 /* player/enemy overlap half-width */ +#define BULLET_KILL_MARGIN 40 + +static const EdgeProto *eproto(const ActEnemy *e) { + return &g.sc->protos[e->proto]; +} + +static void hud_sync(void) { + const EdgeStage *st = act.st; + g.vars[st->kills_var] = (s16)(g.vars[st->kills_var]); /* interned; kills updated on kill */ +} + +static void player_frame(u8 f) { + g.spr[act.pl_slot].mode = 0; + g.spr[act.pl_slot].frame = f; +} + +/* --- spawning ------------------------------------------------------------------- */ +static void enemy_activate(u8 spawn_idx) { + const EdgeStage *st = act.st; + const EdgeSpawn *sp = &st->spawns[spawn_idx]; + int i; + for (i = 0; i < C_MAX_ENEMIES; i++) { + ActEnemy *e = &act.en[i]; + if (e->active) continue; + e->active = 1; + e->spawn_idx = spawn_idx; + e->behavior = sp->behavior; + e->proto = sp->proto; + e->hp = sp->hp; + e->wave = sp->wave; + e->x = sp->x; + e->y = st->ground_y; + /* drones hover so a straight shot (muzzle at y-14) grazes them: a 16px + * drone's bottom near ground-18 puts its body across the bullet line */ + e->home_y = (s16)(st->ground_y - 18); + if (sp->behavior == C_EB_DRONE) e->y = e->home_y; + e->vx_q4 = 0; + e->vy_q4 = 0; + e->timer = (u8)(g.rng & 31); + e->tele = 0; + e->face = (u8)(sp->x > act.x); + e->anim = 0; + act.alive++; + if (sp->behavior == C_EB_BOSS) act.boss_hp = sp->hp; + return; + } +} + +static void bullet_fire(s16 x, s16 y, s16 vx_q4, s16 vy_q4, u8 from_enemy, u8 tile) { + int i; + for (i = 0; i < C_MAX_BULLETS; i++) { + ActBullet *b = &act.bl[i]; + if (b->active) continue; + b->active = 1; + b->from_enemy = from_enemy; + b->tile = tile; + b->x = x; + b->y = y; + b->vx_q4 = vx_q4; + b->vy_q4 = vy_q4; + return; + } +} + +static void aim_at_player(const ActEnemy *e, s16 speed_q4, s16 *vx, s16 *vy) { + s16 dx = (s16)(act.x - e->x); + s16 dy = (s16)((act.y - 14) - e->y); + s16 ax = dx < 0 ? (s16)-dx : dx; + *vx = dx < 0 ? (s16)-speed_q4 : speed_q4; + /* shallow vertical lead, quantized so shots stay dodgeable */ + if (dy > 24 && ax > 32) *vy = (s16)(speed_q4 / 2); + else if (dy < -24 && ax > 32) *vy = (s16)(-speed_q4 / 2); + else *vy = 0; +} + +/* --- stage entry / respawn -------------------------------------------------------- */ +void action_start(void) { + const EdgeStage *st = g.sc->stage; + Spr *s; + int i; + if (!st) { /* authoring error surfaced loudly: end the scene */ + vm_push(0); + return; + } + act.active = 1; + act.st = st; + act.done = 0; + act.pl_slot = 0; /* player actor is always slot 0 (first declared actor) */ + s = &g.spr[act.pl_slot]; + { + /* if a cutscene already posed the actor, start where it stands */ + const EdgeProto *pp = &g.sc->protos[st->player_proto]; + act.x = (s->active && s->x > 0) ? (s16)(s->x + pp->w / 2) : 24; + } + s->active = 1; + s->proto = st->player_proto; + s->flags = 0; + act.y = st->ground_y; + act.x_q4 = (s32)act.x << 4; + act.y_q4 = (s32)act.y << 4; + act.vx_q4 = act.vy_q4 = 0; + act.grounded = 1; + act.face_left = 0; + act.shoot_cd = 0; + act.iframes = 0; + act.hurt_timer = 0; + act.sande_on = 0; + act.sande_frames = 0; + act.world_tick = 0; + act.frame = 0; + act.checkpoint_x = act.x; + act.next_spawn = 0; + act.alive = 0; + act.spawned_dead = 0; + act.boss_hp = 0; + act.ghead = 0; + for (i = 0; i < C_MAX_ENEMIES; i++) act.en[i].active = 0; + for (i = 0; i < C_MAX_BULLETS; i++) act.bl[i].active = 0; + for (i = 0; i < C_ACT_GHOSTS; i++) act.gx[i] = -320; + g.vars[st->hp_var] = st->hp_max; + g.vars[st->sande_var] = st->sande_max; + hud_sync(); + g.waiting = WAITING_ACTION; +} + +static void action_finish(s16 result) { + int i; + act.active = 0; + act.sande_on = 0; + /* restore the true palette + park the action OAM ranges */ + dma3_copy32(BG_PAL, g.sc->pal_bg, 512 / 4); + for (i = 0; i < C_MAX_ENEMIES; i++) oam_shadow[C_OAM_ENEMY + i].attr0 = ATTR0_HIDE; + for (i = 0; i < C_MAX_BULLETS; i++) oam_shadow[C_OAM_BULLET + i].attr0 = ATTR0_HIDE; + for (i = 0; i < C_ACT_GHOSTS; i++) oam_shadow[C_OAM_GHOST + i].attr0 = ATTR0_HIDE; + player_frame(C_PF_IDLE); + vm_push(result); + g.waiting = WAITING_RUN; +} + +static void player_hurt(u8 dmg, s16 from_x) { + const EdgeStage *st = act.st; + if (act.iframes || act.done) return; + g.vars[st->hp_var] -= dmg; + act.iframes = C_ACT_IFRAMES; + act.hurt_timer = 14; + act.vx_q4 = (s16)(from_x > act.x ? -40 : 40); /* knockback */ + if (!act.grounded) act.vy_q4 = -24; + sfx_play(C_SFX_WHOOSH); + g.fx[TW_SHAKE] = 3; + if (g.vars[st->hp_var] <= 0) { + int i; + g.vars[st->deaths_var]++; + g.vars[st->hp_var] = st->hp_max; + g.vars[st->sande_var] = st->sande_max; + act.x = act.checkpoint_x; + act.x_q4 = (s32)act.x << 4; + act.y = st->ground_y; + act.y_q4 = (s32)act.y << 4; + act.vx_q4 = act.vy_q4 = 0; + act.iframes = 120; + /* clear the field so the respawn isn't an instant re-kill */ + for (i = 0; i < C_MAX_BULLETS; i++) act.bl[i].active = 0; + g.fx[TW_SHAKE] = 5; + } +} + +static void enemy_hurt(ActEnemy *e, u8 dmg) { + const EdgeStage *st = act.st; + if (dmg >= e->hp) { + e->hp = 0; + e->active = 0; + act.alive--; + act.spawned_dead++; + g.vars[st->kills_var]++; + sfx_play(C_SFX_STAR); + g.fx[TW_SHAKE] = 2; + if (e->behavior == C_EB_BOSS) act.boss_hp = 0; + return; + } + e->hp = (u8)(e->hp - dmg); + if (e->behavior == C_EB_BOSS) { + act.boss_hp = e->hp; + if (st->boss_phase_hp && e->hp <= st->boss_phase_hp && !act.done) { + act.done = 1; /* scripted takeover — story decides what "winning" means */ + action_finish(C_ACT_BOSS_PHASE); + } + } + sfx_play(C_SFX_BLIP); +} + +/* --- per-frame world (enemies + bullets), skipped on sandevistan frames ---------- */ +static void enemies_update(void) { + const EdgeStage *st = act.st; + int i; + for (i = 0; i < C_MAX_ENEMIES; i++) { + ActEnemy *e = &act.en[i]; + const EdgeProto *p; + s16 dx; + if (!e->active) continue; + p = eproto(e); + dx = (s16)(act.x - e->x); + e->face = (u8)(dx < 0); + e->timer++; + e->anim++; + switch (e->behavior) { + case C_EB_THUG: + if (e->tele) { /* lunge in flight */ + e->tele--; + e->x += e->face ? -3 : 3; + } else if (dx > -C_ACT_MELEE_RANGE && dx < C_ACT_MELEE_RANGE) { + if (e->timer > 40) { + e->timer = 0; + e->tele = 14; /* lunge */ + } + } else { + e->x += dx < 0 ? -1 : 1; + } + break; + case C_EB_GUNNER: + if (dx > -44 && dx < 44) e->x += dx < 0 ? 1 : -1; /* keep range */ + if (e->timer == 80 || e->timer == 88 || e->timer == 96) { + s16 vx, vy; + aim_at_player(e, C_ACT_EBULLET_VX, &vx, &vy); + bullet_fire(e->x, (s16)(e->y - 14), vx, vy, 1, C_UIT_EBULLET); + sfx_play(C_SFX_BLIP); + } + if (e->timer > 96) e->timer = 0; + break; + case C_EB_DRONE: { + extern s8 sin8[256]; + e->y = (s16)(e->home_y + (sin8[(u8)(e->anim * 2)] >> 5)); + if (dx > 24) e->x += 1; + else if (dx < -24) e->x -= 1; + if (e->timer > 100) { + s16 vx, vy; + e->timer = 0; + aim_at_player(e, C_ACT_EBULLET_VX, &vx, &vy); + bullet_fire(e->x, (s16)(e->y + 4), vx, (s16)(vy + 10), 1, C_UIT_EBULLET); + sfx_play(C_SFX_BLIP); + } + break; + } + case C_EB_TURRET: + if (e->timer == 60 || e->timer == 75) { + s16 vx, vy; + aim_at_player(e, C_ACT_EBULLET_VX, &vx, &vy); + bullet_fire(e->x, (s16)(e->y - 12), vx, vy, 1, C_UIT_EBULLET); + sfx_play(C_SFX_BLIP); + } + if (e->timer > 75) e->timer = 0; + break; + case C_EB_BOSS: { + /* cycle: stalk -> spread volley -> stalk -> telegraphed charge -> slam */ + u8 phase2 = (u8)(act.boss_hp * 2 < st->spawns[st->boss].hp); /* below half: faster */ + u8 cycle = phase2 ? 160 : 200; + u8 t = (u8)(e->timer % cycle); + if (e->tele) { /* charging */ + e->tele--; + e->x += e->face ? -4 : 4; + if (e->tele == 0) { /* slam shockwaves both ways along the ground */ + bullet_fire(e->x, (s16)(st->ground_y - 4), 40, 0, 1, C_UIT_SHOCK); + bullet_fire(e->x, (s16)(st->ground_y - 4), -40, 0, 1, C_UIT_SHOCK); + g.fx[TW_SHAKE] = 4; + sfx_play(C_SFX_WHOOSH); + } + } else if (t == 70 || t == 78 || t == 86) { /* spread volley */ + s16 vx, vy; + aim_at_player(e, (s16)(C_ACT_EBULLET_VX + (phase2 ? 8 : 0)), &vx, &vy); + bullet_fire(e->x, (s16)(e->y - 40), vx, (s16)(vy - 8), 1, C_UIT_EBULLET); + bullet_fire(e->x, (s16)(e->y - 40), vx, vy, 1, C_UIT_EBULLET); + bullet_fire(e->x, (s16)(e->y - 40), vx, (s16)(vy + 8), 1, C_UIT_EBULLET); + sfx_play(C_SFX_BLIP); + } else if (t == (phase2 ? 120 : 150)) { + e->tele = 26; /* charge (drawn as ATTACK frame = telegraph) */ + sfx_play(C_SFX_CONFIRM); + } else { + e->x += dx < 0 ? -1 : (dx > 0 ? 1 : 0); + if (phase2 && (e->timer & 1)) e->x += dx < 0 ? -1 : 1; + } + break; + } + } + /* contact damage */ + { + s16 adx = (s16)(act.x - e->x); + s16 ady = (s16)(act.y - e->y); + s16 hw = (s16)(p->w / 2 + 2); + if (adx > -hw && adx < hw && ady > -(s16)p->h && ady < 12) + player_hurt(e->behavior == C_EB_BOSS ? 2 : 1, e->x); + } + } +} + +static void bullets_update(void) { + int i; + for (i = 0; i < C_MAX_BULLETS; i++) { + ActBullet *b = &act.bl[i]; + s16 sx; + if (!b->active) continue; + b->x += b->vx_q4 / 16; + b->y += b->vy_q4 / 16; + /* leftover q4 fraction is deliberately dropped: bullets are fast */ + sx = (s16)(b->x - g.fx[TW_CAM_X]); + if (sx < -BULLET_KILL_MARGIN || sx > 240 + BULLET_KILL_MARGIN || b->y < -16 || + b->y > 176) { + b->active = 0; + continue; + } + if (b->from_enemy) { + s16 dx = (s16)(b->x - act.x); + s16 dy = (s16)(b->y - (act.y - 14)); + if (dx > -8 && dx < 8 && dy > -16 && dy < 16) { + b->active = 0; + player_hurt(1, b->x); + } + } else { + int j; + for (j = 0; j < C_MAX_ENEMIES; j++) { + ActEnemy *e = &act.en[j]; + const EdgeProto *p; + s16 dx, dy; + if (!e->active) continue; + p = eproto(e); + dx = (s16)(b->x - e->x); + dy = (s16)(b->y - (e->y - p->h / 2)); + /* vertical tolerance padded by 6px so grazing hits count (forgiving) */ + if (dx > -(s16)(p->w / 2) && dx < (s16)(p->w / 2) && dy > -(s16)(p->h / 2 + 6) && + dy < (s16)(p->h / 2 + 6)) { + b->active = 0; + enemy_hurt(e, 1); + break; + } + } + } + } +} + +/* --- the frame ------------------------------------------------------------------- */ +void action_service(void) { + const EdgeStage *st = act.st; + Spr *s = &g.spr[act.pl_slot]; + const EdgeProto *pp = &g.sc->protos[st->player_proto]; + u8 world_frame; + s16 run_vx; + s16 gate_x = 0x7fff; + int i; + + if (!act.active) return; + act.frame++; + + /* -- sandevistan gauge ---------------------------------------------------------- */ + { + u8 want = (u8)(st->sande_max && key_held(KEY_R) && g.vars[st->sande_var] > 0); + if (want && !act.sande_on) sfx_play(C_SFX_WHOOSH); + if (!want && act.sande_on) dma3_copy32(BG_PAL, g.sc->pal_bg, 512 / 4); + if (want && !act.sande_on && st->pal_sande) dma3_copy32(BG_PAL, st->pal_sande, 512 / 4); + act.sande_on = want; + if (act.sande_on) { + if (++act.sande_frames >= C_ACT_SANDE_DRAIN) { + act.sande_frames = 0; + g.vars[st->sande_var]--; + } + } else if (g.vars[st->sande_var] < st->sande_max) { + if (++act.sande_frames >= C_ACT_SANDE_REGEN) { + act.sande_frames = 0; + g.vars[st->sande_var]++; + } + } + } + world_frame = (u8)(!act.sande_on || (act.frame % C_ACT_SLOW_DIV) == 0); + + /* -- spawns + gates -------------------------------------------------------------- */ + while (act.next_spawn < st->n_spawns && st->spawns[act.next_spawn].x < act.x + SPAWN_AHEAD) { + if (act.alive < C_MAX_ENEMIES) { + enemy_activate(act.next_spawn); + act.next_spawn++; + } else break; + } + for (i = 0; i < st->n_gates; i++) { + const EdgeGate *gt = &st->gates[i]; + int j; + u8 live = 0; + for (j = 0; j < C_MAX_ENEMIES; j++) + if (act.en[j].active && act.en[j].wave == gt->wave) live = 1; + /* the gate holds while its wave has activated members alive, or members + * still queued at/behind the gate line */ + if (!live) { + for (j = act.next_spawn; j < st->n_spawns; j++) + if (st->spawns[j].wave == gt->wave && st->spawns[j].x < gt->x + SPAWN_AHEAD) live = 1; + } + if (live && gt->x < gate_x) gate_x = gt->x; + if (!live && act.checkpoint_x < gt->x) act.checkpoint_x = gt->x; /* checkpoint */ + } + + /* -- player --------------------------------------------------------------------- */ + run_vx = act.sande_on ? C_ACT_SANDE_VX : C_ACT_RUN_VX; + if (act.hurt_timer) { + act.hurt_timer--; + } else { + if (key_held(KEY_LEFT)) { + act.vx_q4 = (s16)-run_vx; + act.face_left = 1; + } else if (key_held(KEY_RIGHT)) { + act.vx_q4 = run_vx; + act.face_left = 0; + } else { + act.vx_q4 = 0; + } + if (key_pressed(KEY_A) && act.grounded) { + act.vy_q4 = C_ACT_JUMP_VY; + act.grounded = 0; + sfx_play(C_SFX_CONFIRM); + } + } + /* gravity + integrate */ + if (!act.grounded) act.vy_q4 = (s16)(act.vy_q4 + C_ACT_GRAVITY); + act.x_q4 += act.vx_q4; + act.y_q4 += act.vy_q4; + act.x = (s16)(act.x_q4 >> 4); + act.y = (s16)(act.y_q4 >> 4); + + /* bounds: stage [8, length-8], gate line */ + { + s16 max_x = (s16)(st->length - 8); + if (gate_x != 0x7fff && gate_x < max_x) max_x = gate_x; + if (act.x < 8) act.x = 8; + if (act.x > max_x) act.x = max_x; + act.x_q4 = (s32)act.x << 4; + } + /* ground + one-way platforms (land only while falling) */ + { + s16 floor_y = st->ground_y; + for (i = 0; i < st->n_plats; i++) { + const EdgePlat *pl = &st->plats[i]; + if (act.x >= pl->x && act.x < pl->x + pl->w && act.vy_q4 >= 0 && act.y >= pl->y && + act.y <= pl->y + 6 && pl->y < floor_y) + floor_y = pl->y; + } + if (act.vy_q4 >= 0 && act.y >= floor_y) { + act.y = floor_y; + act.y_q4 = (s32)act.y << 4; + act.vy_q4 = 0; + act.grounded = 1; + } else if (act.y < floor_y) { + act.grounded = 0; + } + } + + /* -- shooting -------------------------------------------------------------------- */ + if (act.shoot_cd) act.shoot_cd--; + if (!act.hurt_timer && key_pressed(KEY_B) && !act.shoot_cd) { + u8 melee = 0; + act.shoot_cd = act.sande_on ? (u8)(C_ACT_SHOOT_CD / 2) : C_ACT_SHOOT_CD; + for (i = 0; i < C_MAX_ENEMIES; i++) { + ActEnemy *e = &act.en[i]; + s16 dx; + if (!e->active) continue; + dx = (s16)(e->x - act.x); + if (dx > -C_ACT_MELEE_RANGE && dx < C_ACT_MELEE_RANGE && + (act.y - e->y) > -28 && (act.y - e->y) < 20) { + enemy_hurt(e, 2); /* point-blank: pistol-whip */ + melee = 1; + break; + } + } + if (!act.done && !melee) { + /* hold UP for a steep rising diagonal — the anti-drone shot */ + s16 bvy = key_held(KEY_UP) ? (s16)(-C_ACT_BULLET_VX * 3 / 4) : 0; + bullet_fire((s16)(act.x + (act.face_left ? -10 : 10)), (s16)(act.y - 14), + (s16)(act.face_left ? -C_ACT_BULLET_VX : C_ACT_BULLET_VX), bvy, 0, + C_UIT_PBULLET); + sfx_play(C_SFX_BLIP); + } + } + if (act.done) return; /* boss-phase finish fired from enemy_hurt */ + + /* -- world tick (slowed under sandevistan) --------------------------------------- */ + if (world_frame) { + enemies_update(); + bullets_update(); + act.world_tick++; + } + if (act.done) return; + + /* -- iframes + camera + sprite pose ---------------------------------------------- */ + if (act.iframes) act.iframes--; + { + s16 target = (s16)(act.x - 104); + s16 cmax = (s16)(st->length - 240); + if (target < 0) target = 0; + if (target > cmax) target = cmax; + g.fx[TW_CAM_X] += (s16)((target - g.fx[TW_CAM_X]) >> 3); + } + { + u8 f; + if (act.hurt_timer) f = C_PF_HURT; + else if (!act.grounded) f = C_PF_JUMP; + else if (act.shoot_cd > C_ACT_SHOOT_CD - 5) f = C_PF_SHOOT; + else if (act.vx_q4) f = (u8)(C_PF_RUN0 + ((act.frame >> (act.sande_on ? 2 : 3)) & 3)); + else f = C_PF_IDLE; + player_frame(f); + s->x = (s16)(act.x - pp->w / 2); + s->y = (s16)(act.y - pp->h); + s->flags = act.face_left ? (u8)(s->flags | C_SPR_HFLIP) : (u8)(s->flags & ~C_SPR_HFLIP); + /* iframe blink */ + s->active = (u8)(!(act.iframes & 2)); + } + /* afterimage ring (every 4th frame while sandevistan is engaged) */ + if (act.sande_on && (act.frame & 3) == 0) { + act.gx[act.ghead] = s->x; + act.gy[act.ghead] = s->y; + act.gframe[act.ghead] = g.spr[act.pl_slot].frame; + act.gface[act.ghead] = act.face_left; + act.ghead = (u8)((act.ghead + 1) % C_ACT_GHOSTS); + } + + /* -- exit ------------------------------------------------------------------------- */ + { + u8 cleared = 0; + if (st->exit_kind == C_AEXIT_CLEAR) + cleared = (u8)(act.next_spawn >= st->n_spawns && act.alive == 0); + else + cleared = (u8)(act.x >= st->length - 12); + if (cleared) action_finish(C_ACT_CLEARED); + } +} + +/* --- OAM -------------------------------------------------------------------------- */ +void action_draw(void) { + s16 cam = g.fx[TW_CAM_X]; + int i; + if (!act.active) return; + + for (i = 0; i < C_MAX_ENEMIES; i++) { + ActEnemy *e = &act.en[i]; + ObjAttr *o = &oam_shadow[C_OAM_ENEMY + i]; + const EdgeProto *p; + s16 sx, sy; + u8 frame; + if (!e->active) { + o->attr0 = ATTR0_HIDE; + continue; + } + p = eproto(e); + sx = (s16)(e->x - p->w / 2 - cam); + sy = (s16)(e->y - p->h - g.fx[TW_CAM_Y]); + if (sx <= -(s16)p->w * 2 || sx >= 240 + p->w) { + o->attr0 = ATTR0_HIDE; + continue; + } + if (e->tele || (e->behavior == C_EB_GUNNER && (e->timer >= 76 && e->timer <= 98))) + frame = C_EF_ATTACK; + else if (e->vx_q4 || e->behavior == C_EB_THUG || e->behavior == C_EB_BOSS) + frame = (u8)((e->anim >> 4) & 1 ? C_EF_WALK0 : C_EF_WALK1); + else + frame = (u8)((e->anim >> 5) & 1 ? C_EF_IDLE : C_EF_WALK0); + if (frame >= p->frames) frame = 0; + o->attr0 = (u16)(ATTR0_Y(sy) | ((p->w == p->h) ? ATTR0_SQUARE : (p->w > p->h ? ATTR0_WIDE : ATTR0_TALL))); + o->attr1 = (u16)(ATTR1_X(sx) | (e->face ? ATTR1_HFLIP : 0) | + ATTR1_SIZE(p->w == p->h ? (p->w == 8 ? 0 : p->w == 16 ? 1 : p->w == 32 ? 2 : 3) + : (p->w == 32 && p->h == 16 ? 2 : 3))); + o->attr2 = (u16)(ATTR2_TILE(p->tile_base + frame * ((p->w / 8) * (p->h / 8))) | ATTR2_PRIO(1) | + ATTR2_PALBANK(p->palbank)); + } + + for (i = 0; i < C_MAX_BULLETS; i++) { + ActBullet *b = &act.bl[i]; + ObjAttr *o = &oam_shadow[C_OAM_BULLET + i]; + if (!b->active) { + o->attr0 = ATTR0_HIDE; + continue; + } + if (b->tile == C_UIT_SHOCK) { + o->attr0 = (u16)(ATTR0_Y(b->y - 4) | ATTR0_WIDE); + o->attr1 = (u16)(ATTR1_X(b->x - 8 - cam) | ATTR1_SIZE(0)); /* 16x8 */ + } else { + o->attr0 = (u16)(ATTR0_Y(b->y - 4) | ATTR0_SQUARE); + o->attr1 = (u16)(ATTR1_X(b->x - 4 - cam) | ATTR1_SIZE(0)); /* 8x8 */ + } + o->attr2 = (u16)(ATTR2_TILE(C_OBJ_UI_BASE + b->tile) | ATTR2_PRIO(1) | + ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + } + + /* sandevistan afterimages: ghost-blended copies of recent player poses */ + { + const EdgeProto *pp = &g.sc->protos[act.st->player_proto]; + for (i = 0; i < C_ACT_GHOSTS; i++) { + ObjAttr *o = &oam_shadow[C_OAM_GHOST + i]; + s16 sx = (s16)(act.gx[i] - cam); + if (!act.sande_on || sx <= -64 || sx >= 240) { + o->attr0 = ATTR0_HIDE; + continue; + } + o->attr0 = (u16)(ATTR0_Y(act.gy[i] - g.fx[TW_CAM_Y]) | ATTR0_BLEND | + ((pp->w == pp->h) ? ATTR0_SQUARE : ATTR0_TALL)); + o->attr1 = (u16)(ATTR1_X(sx) | (act.gface[i] ? ATTR1_HFLIP : 0) | + ATTR1_SIZE(pp->w == 8 ? 0 : pp->w == 16 ? 1 : pp->w == 32 ? 2 : 3)); + o->attr2 = (u16)(ATTR2_TILE(pp->tile_base + act.gframe[i] * ((pp->w / 8) * (pp->h / 8))) | + ATTR2_PRIO(1) | ATTR2_PALBANK(pp->palbank)); + } + } +} diff --git a/edge/runtime/audio.c b/edge/runtime/audio.c new file mode 100644 index 0000000..173a27d --- /dev/null +++ b/edge/runtime/audio.c @@ -0,0 +1,69 @@ +/* edge/runtime/audio.c — DirectSound A PCM streaming for insert songs. + * + * Tracks are s8 mono in ROM at C_MUSIC_RATE (13379 Hz — exactly C_MUSIC_SPF + * samples per 59.73 Hz frame, so the frame loop is the stream clock). Timer 0 + * paces the FIFO; DMA1 in special mode refills it straight from the cartridge + * (DMA1/2 may read gamepak — only DMA0 can't). music_service() counts frames + * and restarts/stops the stream at the sample count, so a track end never + * plays garbage ROM as noise. + * + * PSG sfx (sfx.c) keep running on top: SOUNDCNT_H keeps the PSG mix at 100% + * alongside the DirectSound channel. */ +#include "edge.h" + +static struct { + u8 playing; /* track + 1 */ + u32 frames; /* frames since (re)start */ +} mus; + +void music_boot(void) { + mus.playing = 0; +} + +static void dma_arm(const s8 *pcm) { + REG_DMA1CNT = 0; + REG_SOUNDCNT_H = SND_DSA_ON | SND_DSA_RESET; /* reset FIFO */ + REG_SOUNDCNT_H = SND_DSA_ON; + REG_DMA1SAD = (u32)pcm; + REG_DMA1DAD = (u32)®_FIFO_A; + REG_DMA1CNT = DMA_ENABLE | DMA_REPEAT | DMA_32 | DMA_DST_FIXED | DMA_START_SPECIAL; +} + +void music_play(u8 id) { + const EdgeTrack *t; + if (id >= film.n_tracks) return; + t = &film.tracks[id]; + REG_TM0CNT_H = 0; + REG_TM0CNT_L = C_MUSIC_TIMER; + dma_arm(t->pcm); + REG_TM0CNT_H = TM_ENABLE; + mus.playing = (u8)(id + 1); + mus.frames = 0; +} + +void music_stop(void) { + if (!mus.playing) return; + mus.playing = 0; + REG_DMA1CNT = 0; + REG_TM0CNT_H = 0; + REG_SOUNDCNT_H = 0x0002; /* PSG-only mix again */ +} + +void music_service(void) { + const EdgeTrack *t; + if (!mus.playing) return; + t = &film.tracks[mus.playing - 1]; + mus.frames++; + if (mus.frames * C_MUSIC_SPF >= t->samples) { + if (t->loop) { + dma_arm(t->pcm); /* seamless-enough restart at the loop point */ + mus.frames = 0; + } else { + music_stop(); + } + } +} + +u8 music_playing(void) { + return mus.playing; +} diff --git a/edge/runtime/breakout.c b/edge/runtime/breakout.c new file mode 100644 index 0000000..e6dd551 --- /dev/null +++ b/edge/runtime/breakout.c @@ -0,0 +1,181 @@ +/* edge/runtime/breakout.c — the playable Breakout set piece (Atari, 1976). + * Bricks/ball/paddle are OBJs from the built-in UI sheet over whatever BG the + * scene shows. Deterministic (no rng): launch angle fixed, physics integer. + * OP_BREAKOUT blocks in WAITING_MINIGAME and pushes the number of bricks + * cleared; the game ends on full clear, on running out of lives, or on the + * frame budget expiring — history only needs the night to pass. */ +#include "edge.h" + +#define COURT_X0 24 +#define COURT_X1 216 /* 12 bricks x 16px */ +#define COURT_Y0 16 +#define COURT_Y1 156 +#define BRICK_Y0 32 +#define PADDLE_Y 144 +#define PADDLE_W 32 + +static struct { + u8 active, lives, rows, launched; + u8 left, cleared; + u16 timer, budget; + s16 px; /* paddle left */ + s16 bx_q4, by_q4, dx_q4, dy_q4; /* ball top-left, 12.4 fixed */ + u8 brick[C_BRICK_ROWS_MAX * C_BRICK_COLS]; +} bk; + +u8 breakout_left(void) { + return bk.active ? bk.left : 0; +} + +static void ball_reset(void) { + bk.launched = 0; + bk.bx_q4 = (s16)((bk.px + PADDLE_W / 2 - 4) << 4); + bk.by_q4 = (s16)((PADDLE_Y - 8) << 4); + bk.dx_q4 = 24; /* 1.5 px/frame */ + bk.dy_q4 = -32; /* 2 px/frame up */ +} + +void breakout_start(u8 rows, u8 lives, u16 budget) { + int i; + if (rows > C_BRICK_ROWS_MAX) rows = C_BRICK_ROWS_MAX; + bk.active = 1; + bk.rows = rows; + bk.lives = lives; + bk.cleared = 0; + bk.left = (u8)(rows * C_BRICK_COLS); + bk.timer = 0; + bk.budget = budget ? budget : 3600; + bk.px = 120 - PADDLE_W / 2; + for (i = 0; i < rows * C_BRICK_COLS; i++) bk.brick[i] = 1; + ball_reset(); + g.waiting = WAITING_MINIGAME; +} + +static void finish(void) { + int i; + bk.active = 0; + for (i = 0; i < C_BRICK_ROWS_MAX * C_BRICK_COLS; i++) + oam_shadow[C_OAM_BRICK + i].attr0 = ATTR0_HIDE; + oam_shadow[C_OAM_BALL].attr0 = ATTR0_HIDE; + oam_shadow[C_OAM_PADDLE].attr0 = ATTR0_HIDE; + vm_push((s16)bk.cleared); + g.waiting = WAITING_RUN; +} + +/* returns 1 if a brick at ball point (px coords) was hit */ +static u8 hit_brick(s16 x, s16 y) { + s16 c, r; + if (y < BRICK_Y0 || y >= BRICK_Y0 + bk.rows * 8) return 0; + if (x < COURT_X0 || x >= COURT_X1) return 0; + c = (s16)((x - COURT_X0) >> 4); + r = (s16)((y - BRICK_Y0) >> 3); + if (r < 0 || r >= bk.rows || c < 0 || c >= C_BRICK_COLS) return 0; + if (!bk.brick[r * C_BRICK_COLS + c]) return 0; + bk.brick[r * C_BRICK_COLS + c] = 0; + bk.left--; + bk.cleared++; + sfx_play(C_SFX_BLIP); + return 1; +} + +u8 breakout_service(void) { + s16 bx, by; + if (!bk.active) return 0; + bk.timer++; + + /* paddle */ + if (key_held(KEY_LEFT)) bk.px -= 3; + if (key_held(KEY_RIGHT)) bk.px += 3; + if (bk.px < COURT_X0) bk.px = COURT_X0; + if (bk.px > COURT_X1 - PADDLE_W) bk.px = (s16)(COURT_X1 - PADDLE_W); + + if (!bk.launched) { + bk.bx_q4 = (s16)((bk.px + PADDLE_W / 2 - 4) << 4); + if (key_pressed(KEY_A)) { + bk.launched = 1; + sfx_play(C_SFX_CONFIRM); + } + } else { + bk.bx_q4 += bk.dx_q4; + bk.by_q4 += bk.dy_q4; + bx = (s16)(bk.bx_q4 >> 4); + by = (s16)(bk.by_q4 >> 4); + + if (bx <= COURT_X0) { + bk.bx_q4 = COURT_X0 << 4; + bk.dx_q4 = (s16)-bk.dx_q4; + } else if (bx >= COURT_X1 - 8) { + bk.bx_q4 = (s16)((COURT_X1 - 8) << 4); + bk.dx_q4 = (s16)-bk.dx_q4; + } + if (by <= COURT_Y0) { + bk.by_q4 = COURT_Y0 << 4; + bk.dy_q4 = (s16)-bk.dy_q4; + } + + bx = (s16)(bk.bx_q4 >> 4); + by = (s16)(bk.by_q4 >> 4); + + /* bricks: test ball center against the grid, flip vertical on hit */ + if (hit_brick((s16)(bx + 4), (s16)(bk.dy_q4 < 0 ? by : by + 8))) { + bk.dy_q4 = (s16)-bk.dy_q4; + g.fx[TW_SHAKE] = 1; + } + + /* paddle catch */ + if (bk.dy_q4 > 0 && by + 8 >= PADDLE_Y && by + 8 < PADDLE_Y + 6 && bx + 8 > bk.px && + bx < bk.px + PADDLE_W) { + s16 off = (s16)((bx + 4) - (bk.px + PADDLE_W / 2)); /* -16..16 */ + bk.dy_q4 = (s16)-bk.dy_q4; + bk.dx_q4 = (s16)(off * 2); + if (bk.dx_q4 > 40) bk.dx_q4 = 40; + if (bk.dx_q4 < -40) bk.dx_q4 = -40; + if (bk.dx_q4 > -8 && bk.dx_q4 < 8) bk.dx_q4 = bk.dx_q4 < 0 ? -8 : 8; + sfx_play(C_SFX_BLIP); + } + + /* lost ball */ + if (by > COURT_Y1) { + if (--bk.lives == 0) { + finish(); + return 0; + } + ball_reset(); + } + } + + if (bk.left == 0 || bk.timer >= bk.budget) { + finish(); + return 0; + } + return 1; +} + +void breakout_draw(void) { + int r, c; + if (!bk.active) return; + for (r = 0; r < bk.rows; r++) { + for (c = 0; c < C_BRICK_COLS; c++) { + ObjAttr *o = &oam_shadow[C_OAM_BRICK + r * C_BRICK_COLS + c]; + if (!bk.brick[r * C_BRICK_COLS + c]) { + o->attr0 = ATTR0_HIDE; + continue; + } + o->attr0 = (u16)(ATTR0_Y(BRICK_Y0 + r * 8) | ATTR0_WIDE); + o->attr1 = (u16)(ATTR1_X(COURT_X0 + c * 16) | (0 << 14)); /* 16x8 */ + o->attr2 = (u16)(ATTR2_TILE(C_OBJ_UI_BASE + 26) | ATTR2_PRIO(1) | ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + } + } + { + ObjAttr *o = &oam_shadow[C_OAM_BALL]; + o->attr0 = (u16)(ATTR0_Y(bk.by_q4 >> 4) | ATTR0_SQUARE); + o->attr1 = (u16)(ATTR1_X(bk.bx_q4 >> 4) | (0 << 14)); /* 8x8 */ + o->attr2 = (u16)(ATTR2_TILE(C_OBJ_UI_BASE + 28) | ATTR2_PRIO(1) | ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + } + { + ObjAttr *o = &oam_shadow[C_OAM_PADDLE]; + o->attr0 = (u16)(ATTR0_Y(PADDLE_Y) | ATTR0_WIDE); + o->attr1 = (u16)(ATTR1_X(bk.px) | (1 << 14)); /* 32x8 */ + o->attr2 = (u16)(ATTR2_TILE(C_OBJ_UI_BASE + 29) | ATTR2_PRIO(1) | ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + } +} diff --git a/edge/runtime/caption.c b/edge/runtime/caption.c new file mode 100644 index 0000000..e49e33e --- /dev/null +++ b/edge/runtime/caption.c @@ -0,0 +1,317 @@ +/* edge/runtime/caption.c — typewriter captions, dialog and choice UI on BG0. + * + * Text tokens (compiler-paginated, <=2 lines x 26 cells): + * 0x00 end · 0x0a newline · 0x20..0x7e ASCII halfcell · 0x80|hi,lo fullwidth + * Glyphs are 8x16 halfcells: two stacked 4bpp tiles (64 bytes) DMA'd into a + * ring of C_GLYPH_SLOTS slots inside shared charblock 2, one halfcell per + * frame (typewriter) or synchronously (choice menus, speaker chips). + * Halfcells are baked ink-on-box-color, so text always sits on the navy bar. */ +#include "edge.h" + +#define UI_MAP ((u16 *)SCREENBLOCK(C_SBB_UI)) + +/* per-style geometry */ +typedef struct { + u8 box_r0, box_rows; /* cleared/boxed region */ + u8 text_r0; /* first text row (lines at +0 and +2) */ + u8 margin; /* left col for text (0xff = center per line) */ + u8 boxed; /* draw T_BOX under the region */ +} CapGeo; + +static const CapGeo GEO[4] = { + /* CHIP */ {1, 2, 1, 1, 1}, + /* SUB */ {14, 6, 15, 2, 1}, + /* CARD */ {8, 4, 8, 0xff, 0}, + /* DIALOG*/ {12, 8, 15, 2, 1}, +}; + +typedef struct { + const u8 *tok; + u8 style; + u8 line; /* 0/1 */ + u8 col; + u8 pending; /* second halfcell of a fullwidth glyph (hc+1), 0 = none */ + u8 starts[C_CAP_LINES]; + u8 emitted; +} Typing; + +static Typing ty; +static u8 choice_active_n; +static u8 choice_r0; + +static const u8 *text_tokens(u16 id) { + return film.text_blob + film.text_offs[id]; +} + +/* measure token stream: cell widths per line */ +static u8 measure(const u8 *t, u8 *w0, u8 *w1) { + u8 w[C_CAP_LINES] = {0, 0}; + u8 line = 0; + for (;;) { + u8 c = *t++; + if (c == C_TOK_END) break; + if (c == C_TOK_NL) { + if (++line >= C_CAP_LINES) break; + continue; + } + if (c & 0x80) { + t++; /* low byte */ + w[line] = (u8)(w[line] + 2); + } else { + w[line]++; + } + } + *w0 = w[0]; + *w1 = w[1]; + return (u8)(line + 1); +} + +static void wipe_rows(u8 r0, u8 rows) { + int r, c; + for (r = r0; r < r0 + rows; r++) + for (c = 0; c < 32; c++) UI_MAP[r * 32 + c] = 0; +} + +static void box_rows(u8 r0, u8 rows) { + int r, c; + for (r = r0; r < r0 + rows; r++) + for (c = 0; c < 30; c++) UI_MAP[r * 32 + c] = SE(C_T_BOX, C_PALBANK_UI); +} + +static void accent_row(u8 r, u8 c0, u8 c1) { + int c; + for (c = c0; c < c1; c++) UI_MAP[r * 32 + c] = SE(C_T_BOX_ACCENT, C_PALBANK_UI); +} + +/* Slot plan: the chip caption (place/date) persists across a whole scene while + * subs/dialogs churn, so it gets a private slot range; everything else shares + * a ring above it. */ +#define CHIP_SLOTS 24 +static u16 chip_next; + +static u8 cur_region; /* 0 = general ring, 1 = chip range */ + +static void emit_halfcell(u16 hc, u8 col, u8 row) { + u16 slot; + u16 tile; + if (cur_region) { + slot = chip_next; + chip_next = (u16)((slot + 1) % CHIP_SLOTS); + } else { + slot = g.slot_next; + g.slot_next = (u16)(slot + 1); + if (g.slot_next >= C_GLYPH_SLOTS) g.slot_next = CHIP_SLOTS; + } + tile = (u16)(C_GLYPH_SLOT_BASE + slot * 2); + dma3_copy32(CHARBLOCK(C_CBB_SHARED) + tile * 16, film.glyphs + (u32)hc * 64, 64 / 4); + UI_MAP[row * 32 + col] = SE(tile, C_PALBANK_UI); + UI_MAP[(row + 1) * 32 + col] = (u16)SE(tile + 1, C_PALBANK_UI); +} + +/* fullwidth halfcell ids exceed a u8; the pending right half lives in a u16 */ +static u16 ty_pend16; + +static void type_start(u8 style, u16 text_id) { + const CapGeo *ge = &GEO[style]; + const u8 *t = text_tokens(text_id); + u8 w0, w1; + measure(t, &w0, &w1); + ty.tok = t; + ty.style = style; + ty.line = 0; + ty.emitted = 0; + ty_pend16 = 0; + ty.pending = 0; + if (ge->margin == 0xff) { + ty.starts[0] = (u8)((30 - w0) / 2); + ty.starts[1] = (u8)((30 - (w1 ? w1 : w0)) / 2); + } else { + ty.starts[0] = ge->margin; + ty.starts[1] = ge->margin; + } + ty.col = ty.starts[0]; + g.caption_busy = 1; +} + +/* one halfcell per frame, honoring a pending fullwidth right half */ +void caption_update(void) { + const CapGeo *ge; + u8 row; + if (!g.caption_busy) return; + cur_region = (ty.style == C_CAP_CHIP); + ge = &GEO[ty.style]; + row = (u8)(ge->text_r0 + ty.line * 2); + if (ty_pend16) { + emit_halfcell(ty_pend16, ty.col++, row); + ty_pend16 = 0; + return; + } + for (;;) { + u8 c; + if (!ty.tok) { + g.caption_busy = 0; + return; + } + c = *ty.tok++; + if (c == C_TOK_END) { + ty.tok = 0; + g.caption_busy = 0; + return; + } + if (c == C_TOK_NL) { + ty.line++; + if (ty.line >= C_CAP_LINES) { + ty.tok = 0; + g.caption_busy = 0; + return; + } + ty.col = ty.starts[ty.line]; + row = (u8)(ge->text_r0 + ty.line * 2); + continue; + } + if (c & 0x80) { + u16 gid = (u16)(((c & 0x7f) << 8) | *ty.tok++); + u16 hc = (u16)(C_ASCII_HALF + gid * 2); + emit_halfcell(hc, ty.col++, row); + ty_pend16 = (u16)(hc + 1); + } else { + emit_halfcell((u16)(c - 0x20), ty.col++, row); + } + if ((++ty.emitted & 3) == 1) sfx_play(C_SFX_BLIP); + return; + } +} + +u8 caption_typing(void) { + return g.caption_busy; +} + +/* draw a whole token stream synchronously at (col0, row) */ +static void render_now(const u8 *t, u8 col0, u8 row) { + u8 col = col0; + cur_region = 0; + for (;;) { + u8 c = *t++; + if (c == C_TOK_END) break; + if (c == C_TOK_NL) { + row += 2; + col = col0; + continue; + } + if (c & 0x80) { + u16 gid = (u16)(((c & 0x7f) << 8) | *t++); + u16 hc = (u16)(C_ASCII_HALF + gid * 2); + emit_halfcell(hc, col++, row); + emit_halfcell((u16)(hc + 1), col++, row); + } else { + emit_halfcell((u16)(c - 0x20), col++, row); + } + } +} + +void caption_show(u8 style, u16 text_id) { + const CapGeo *ge = &GEO[style]; + /* finish any in-flight typing instantly */ + while (g.caption_busy) caption_update(); + wipe_rows(ge->box_r0, ge->box_rows); + if (ge->boxed) { + if (style == C_CAP_CHIP) { + u8 w0, w1; + measure(text_tokens(text_id), &w0, &w1); + { + int r, c; + for (r = ge->box_r0; r < ge->box_r0 + 2; r++) + for (c = 0; c < 2 + w0; c++) UI_MAP[r * 32 + c] = SE(C_T_BOX, C_PALBANK_UI); + accent_row((u8)(ge->box_r0 + 2), 0, (u8)(2 + w0)); + } + } else { + box_rows(ge->box_r0, ge->box_rows); + } + } + type_start(style, text_id); + g.cur_text = (u16)(text_id + 1); +} + +void caption_clear(u8 style) { + if (style == 0xff) { + wipe_rows(0, 20); + } else { + const CapGeo *ge = &GEO[style]; + wipe_rows(ge->box_r0, ge->box_rows); + if (style == C_CAP_CHIP) wipe_rows((u8)(ge->box_r0 + 2), 1); + } + if (g.caption_busy && (style == 0xff || style == ty.style)) { + ty.tok = 0; + ty_pend16 = 0; + g.caption_busy = 0; + } +} + +void caption_dialog(u16 speaker, u16 body) { + const CapGeo *ge = &GEO[C_CAP_DIALOG]; + while (g.caption_busy) caption_update(); + wipe_rows(ge->box_r0, ge->box_rows); + box_rows(ge->box_r0, ge->box_rows); + render_now(text_tokens(speaker), 2, (u8)(ge->box_r0 + 0)); /* rows 12-13 */ + accent_row((u8)(ge->box_r0 + 2), 1, 29); /* row 14 */ + type_start(C_CAP_DIALOG, body); /* rows 15.. */ + g.cur_text = (u16)(body + 1); +} + +/* --- choice menu --------------------------------------------------------------- */ +static void choice_cursor_draw(u8 on) { + u8 r = (u8)(choice_r0 + g.choice_cursor * 2); + UI_MAP[r * 32 + 2] = on ? SE(C_T_CURSOR, C_PALBANK_UI) : SE(C_T_BOX, C_PALBANK_UI); +} + +void choice_show(u8 n, const u16 *ids) { + int i; + while (g.caption_busy) caption_update(); + g.choice_n = n; + g.choice_cursor = 0; + choice_r0 = (u8)(18 - 2 * n); + choice_active_n = n; + wipe_rows((u8)(choice_r0 - 1), (u8)(2 * n + 3)); + box_rows((u8)(choice_r0 - 1), (u8)(2 * n + 3)); + for (i = 0; i < n; i++) render_now(text_tokens(ids[i]), 4, (u8)(choice_r0 + i * 2)); + choice_cursor_draw(1); +} + +void choice_update(void) { + if (!choice_active_n) return; + if (key_pressed(KEY_UP) && g.choice_cursor > 0) { + choice_cursor_draw(0); + g.choice_cursor--; + choice_cursor_draw(1); + sfx_play(C_SFX_BLIP); + } + if (key_pressed(KEY_DOWN) && g.choice_cursor + 1 < g.choice_n) { + choice_cursor_draw(0); + g.choice_cursor++; + choice_cursor_draw(1); + sfx_play(C_SFX_BLIP); + } +} + +u8 choice_done(s8 *out) { + if (!choice_active_n) return 0; + if (key_pressed(KEY_A)) { + *out = (s8)g.choice_cursor; + wipe_rows((u8)(choice_r0 - 1), (u8)(2 * choice_active_n + 3)); + choice_active_n = 0; + sfx_play(C_SFX_CONFIRM); + return 1; + } + return 0; +} + +void caption_boot(void) { + wipe_rows(0, 20); + ty.tok = 0; + ty_pend16 = 0; + g.caption_busy = 0; + g.slot_next = CHIP_SLOTS; + chip_next = 0; + cur_region = 0; + choice_active_n = 0; +} diff --git a/edge/runtime/crt0.s b/edge/runtime/crt0.s new file mode 100644 index 0000000..dafb64f --- /dev/null +++ b/edge/runtime/crt0.s @@ -0,0 +1,53 @@ +@ edge/runtime/crt0.s — GBA cartridge header + startup for @pocketjs/edge. +@ Logo (0x04) and complement (0xBD) are patched by edge/compiler/rom.ts. + .section .init, "ax" + .global _start + .cpu arm7tdmi + .align 2 + .arm +_start: + b .Lstart + .space 156, 0 @ Nintendo logo (patched) + .ascii "EDGE " @ 0xA0: title (12) + .ascii "PJCE" @ 0xAC: game code + .ascii "PJ" @ 0xB0: maker code + .byte 0x96 + .byte 0x00 + .byte 0x00 + .space 7, 0 + .byte 0x00 + .byte 0x00 @ complement (patched) + .space 2, 0 +.Lstart: + @ IRQ-mode stack + msr cpsr_c, #0xD2 + ldr sp, =0x03007FA0 + @ system-mode stack, IRQs ENABLED at the CPU (the edge runtime is + @ interrupt-driven: VBlank frame sync + HBlank raster FX) + msr cpsr_c, #0x5F + ldr sp, =0x03007F00 + + @ copy .data + .iwram code (ROM LMA -> IWRAM VMA) + ldr r0, =__data_lma + ldr r1, =__data_start + ldr r2, =__data_end +.Lcopy: + cmp r1, r2 + ldrlo r3, [r0], #4 + strlo r3, [r1], #4 + blo .Lcopy + + @ zero .bss + ldr r1, =__bss_start + ldr r2, =__bss_end + mov r3, #0 +.Lbss: + cmp r1, r2 + strlo r3, [r1], #4 + blo .Lbss + + ldr r0, =main + mov lr, pc + bx r0 +.Lhang: + b .Lhang diff --git a/edge/runtime/cue_vm.c b/edge/runtime/cue_vm.c new file mode 100644 index 0000000..c332d77 --- /dev/null +++ b/edge/runtime/cue_vm.c @@ -0,0 +1,445 @@ +/* edge/runtime/cue_vm.c — the suspendable cue interpreter. Non-blocking ops + * (tweens, sprite ops, raster, sfx) execute in a burst each frame; blocking + * ops (wait/fade/caption/dialog/choice/control/mash) park the VM in a waiting + * state that vm_tick services first. */ +#include "edge.h" + +static u8 rd8(void) { + return g.sc->cue[g.ip++]; +} +static u16 rd16(void) { + u16 v = (u16)(g.sc->cue[g.ip] | (g.sc->cue[g.ip + 1] << 8)); + g.ip += 2; + return v; +} +static s16 rds16(void) { + return (s16)rd16(); +} + +static void push(s16 v) { + if (g.sp < 8) g.stack[g.sp++] = v; +} +static s16 pop(void) { + return g.sp ? g.stack[--g.sp] : 0; +} + +void vm_push(s16 v) { + push(v); +} + +/* free-roam interrupt: run an NPC/trigger cue, then resume the in-flight + * OP_WORLD (ip is saved because the sub-cue moves it) */ +void vm_run_sub(u8 cue) { + if (cue == 0xff || cue >= g.sc->n_cues) return; + g.ret_ip = g.ip; + g.in_sub = 1; + g.ip = g.sc->cue_offs[cue]; + g.waiting = WAITING_RUN; +} + +void vm_start(void) { + g.ip = 0; + g.sp = 0; + g.waiting = WAITING_RUN; +} + +/* --- waiting-state service ---------------------------------------------------- */ +static void service_control(void) { + Spr *s = &g.spr[g.ctl_slot]; + static s16 frac; + s16 step = 0; + if (key_held(KEY_LEFT)) step = (s16)-g.ctl_speed_q4; + else if (key_held(KEY_RIGHT)) step = (s16)g.ctl_speed_q4; + if (step) { + frac += step; + s->x += frac / 16; + frac %= 16; + s->flags = step < 0 ? (u8)(s->flags | C_SPR_HFLIP) : (u8)(s->flags & ~C_SPR_HFLIP); + s->mode = 1; /* walk anim */ + } else { + s->mode = 0; + s->frame = 0; + } + /* soft camera follow */ + { + s16 target = (s16)(s->x - 120); + if (target < g.sc->cam_min) target = g.sc->cam_min; + if (target > g.sc->cam_max) target = g.sc->cam_max; + g.fx[TW_CAM_X] += (s16)((target - g.fx[TW_CAM_X]) >> 3); + } + /* clamp actor to the pannable world */ + if (s->x < g.sc->cam_min + 8) s->x = (s16)(g.sc->cam_min + 8); + if (s->x > g.sc->cam_max + 232) s->x = (s16)(g.sc->cam_max + 232); + if (s->x > g.ctl_exit - 3 && s->x < g.ctl_exit + 3) { + s->mode = 0; + s->frame = 0; + g.waiting = WAITING_RUN; + } +} + +static void service(void) { + switch (g.waiting) { + case WAITING_BUSY: + if (g.wk_active) { + walk_service(); + if (!g.wk_active && !g.wait_frames && !g.caption_busy) g.waiting = WAITING_RUN; + break; + } + if (g.wait_frames) { + g.wait_frames--; + if (g.wait_frames == 0 && !g.caption_busy) g.waiting = WAITING_RUN; + } else if (!g.caption_busy) { + g.waiting = WAITING_RUN; + } + break; + case WAITING_WORLD: + world_service(); + break; + case WAITING_MINIGAME: + breakout_service(); + break; + case WAITING_ACTION: + action_service(); + break; + case WAITING_A: + g.prompt_on = 1; + if (key_pressed(KEY_A)) { + g.prompt_on = 0; + sfx_play(C_SFX_CONFIRM); + g.waiting = WAITING_RUN; + } + break; + case WAITING_DIALOG: + if (g.caption_busy) { + if (key_pressed(KEY_A)) { /* fast-forward typing */ + while (g.caption_busy) caption_update(); + } + break; + } + g.prompt_on = 1; + if (key_pressed(KEY_A)) { + g.prompt_on = 0; + caption_clear(C_CAP_DIALOG); + sfx_play(C_SFX_CONFIRM); + g.waiting = WAITING_RUN; + } + break; + case WAITING_CHOICE: { + s8 out; + choice_update(); + if (choice_done(&out)) { + g.last_choice = out; + push(out); + g.waiting = WAITING_RUN; + } + break; + } + case WAITING_CONTROL: + service_control(); + break; + case WAITING_MASH: + if (key_pressed(KEY_A)) { + g.vars[g.mash_var]++; + sfx_play(C_SFX_STAR); + } + if (g.vars[g.mash_var] >= (s16)g.mash_target) g.waiting = WAITING_RUN; + break; + default: + break; + } +} + +/* wait for all tweens */ +static u8 tweens_running(void) { + return g.tween_mask != 0; +} + +void vm_tick(void) { + int budget = 64; + + if (g.waiting == WAITING_FILM_DONE) return; + if (g.waiting != WAITING_RUN) { + service(); + if (g.waiting != WAITING_RUN) return; + } + + while (budget-- > 0) { + u8 op = rd8(); + switch (op) { + case OP_END: + if (g.in_sub) { + /* NPC/trigger cue done: resume the roam the player never left */ + g.in_sub = 0; + g.ip = g.ret_ip; + g.waiting = WAITING_WORLD; + return; + } + if (g.scene + 1 < film.n_scenes) { + g.pending_scene = (u8)(g.scene + 1); + } else { + g.film_done = 1; + g.waiting = WAITING_FILM_DONE; + } + return; + case OP_WAIT: + g.wait_frames = rd16(); + g.waiting = WAITING_BUSY; + return; + case OP_WAITA: + g.waiting = WAITING_A; + return; + case OP_WAIT_TWEENS: + if (tweens_running()) { + g.ip--; /* re-run this op next frame */ + return; + } + break; + case OP_FADE: { + u8 mode = rd8(); + u16 T = rd16(); + g.fade_mode = mode; + tween_start(TW_BLDY, (mode == C_FADE_IN_BLACK || mode == C_FADE_IN_WHITE) ? 0 : 16, T, + C_EASE_LINEAR); + g.wait_frames = T; + g.waiting = WAITING_BUSY; + return; + } + case OP_CAPTION: { + u8 style = rd8(); + u16 id = rd16(); + caption_show(style, id); + g.waiting = WAITING_BUSY; + g.wait_frames = 0; + return; + } + case OP_CAPTION_CLR: + caption_clear(rd8()); + break; + case OP_DIALOG: { + u16 sp = rd16(); + u16 body = rd16(); + caption_dialog(sp, body); + g.waiting = WAITING_DIALOG; + return; + } + case OP_CHOICE: { + u8 n = rd8(); + int i; + for (i = 0; i < n; i++) g.choice_ids[i] = rd16(); + choice_show(n, g.choice_ids); + g.waiting = WAITING_CHOICE; + return; + } + case OP_TWEEN: { + u8 target = rd8(); + u8 ease = rd8(); + s16 to = rds16(); + u16 T = rd16(); + tween_start(target, to, T, ease); + break; + } + case OP_SPRITE_SHOW: { + u8 slot = rd8(); + u8 proto = rd8(); + s16 x = rds16(); + s16 y = rds16(); + u8 flags = rd8(); + Spr *s = &g.spr[slot]; + s->active = 1; + s->proto = proto; + s->x = x; + s->y = y; + s->flags = flags; + s->mode = 0; + s->frame = 0; + s->timer = 0; + s->fps = g.sc->protos[proto].fps; + s->affine = 0; + break; + } + case OP_SPRITE_HIDE: + g.spr[rd8()].active = 0; + break; + case OP_SPRITE_ANIM: { + u8 slot = rd8(); + u8 mode = rd8(); + u8 arg = rd8(); + Spr *s = &g.spr[slot]; + s->mode = mode; + if (mode == 0) s->frame = arg; + else if (arg) s->fps = arg; + break; + } + case OP_SPRITE_MOVE: { + u8 slot = rd8(); + u8 ease = rd8(); + s16 x = rds16(); + s16 y = rds16(); + u16 T = rd16(); + tween_start((u8)(C_TW_SPRITE_BASE + slot * 2), x, T, ease); + tween_start((u8)(C_TW_SPRITE_BASE + slot * 2 + 1), y, T, ease); + break; + } + case OP_CONTROL: + g.ctl_slot = rd8(); + g.ctl_exit = rds16(); + g.ctl_speed_q4 = rd8(); + g.waiting = WAITING_CONTROL; + return; + case OP_MASH: + g.mash_var = rd8(); + g.mash_target = rd16(); + g.waiting = WAITING_MASH; + return; + case OP_GOTO_SCENE: + g.pending_scene = rd8(); + return; + case OP_RASTER: { + u8 mode = rd8(); + u8 amp = rd8(); + g.raster_mode = mode; + if (mode == C_RASTER_WAVE_MAIN || mode == C_RASTER_WAVE_FAR) g.fx[TW_WAVE_AMP] = amp; + break; + } + case OP_SFX: + sfx_play(rd8()); + break; + case OP_COUNTER: { + u8 var = rd8(); + u8 show = rd8(); + s16 x = rds16(); + s16 y = rds16(); + g.counter_var = var; + g.counter_show = show; + g.counter_x = x; + g.counter_y = y; + g.counter_prev = g.vars[var]; + break; + } + case OP_AFFINE: { + u8 slot = rd8(); + g.spr[slot].affine = rd8(); + break; + } + case OP_LETTERBOX: { + u8 px = rd8(); + u16 T = rd16(); + tween_start(TW_LETTERBOX, px, T, C_EASE_INOUT); + break; + } + case OP_WORLD: + world_enter(); + return; + case OP_BREAKOUT: { + u8 rows = rd8(); + u8 lives = rd8(); + u16 budget = rd16(); + breakout_start(rows, lives, budget); + return; + } + case OP_ACTION: + action_start(); + return; + case OP_MUSIC: { + u8 track = rd8(); + if (track == 0xff) music_stop(); + else music_play(track); + break; + } + case OP_METER: { + u8 id = (u8)(rd8() & 1); + u8 var = rd8(); + s16 x = rds16(); + s16 y = rds16(); + u8 max = rd8(); + u8 show = rd8(); + g.meter_var[id] = var; + g.meter_x[id] = x; + g.meter_y[id] = y; + g.meter_max[id] = max ? max : 1; + g.meter_on[id] = show; + break; + } + case OP_WARP: { + u8 cx = rd8(); + u8 cy = rd8(); + u8 dir = rd8(); + world_warp(cx, cy, dir); + break; + } + case OP_FACE: { + u8 slot = rd8(); + u8 dir = rd8(); + world_face(slot, dir); + break; + } + case OP_WALK: { + u8 slot = rd8(); + u8 cx = rd8(); + u8 cy = rd8(); + walk_start(slot, cx, cy); + g.waiting = WAITING_BUSY; + return; + } + case OP_PUSH: + push(rds16()); + break; + case OP_SET_VAR: + g.vars[rd8()] = pop(); + break; + case OP_GET_VAR: + push(g.vars[rd8()]); + break; + case OP_ADD_VAR: { + u8 v = rd8(); + g.vars[v] = (s16)(g.vars[v] + rds16()); + break; + } + case OP_SET_FLAG: + FLAG_SET(rd8()); + break; + case OP_CLR_FLAG: + FLAG_CLR(rd8()); + break; + case OP_GET_FLAG: + push((s16)FLAG_GET(rd8())); + break; + case OP_CMP: { + u8 k = rd8(); + s16 b = pop(); + s16 a = pop(); + s16 r = 0; + switch (k) { + case C_CMP_EQ: r = a == b; break; + case C_CMP_NE: r = a != b; break; + case C_CMP_LT: r = a < b; break; + case C_CMP_GT: r = a > b; break; + case C_CMP_LE: r = a <= b; break; + case C_CMP_GE: r = a >= b; break; + } + push(r); + break; + } + case OP_JZ: { + u16 to = rd16(); + if (pop() == 0) g.ip = to; + break; + } + case OP_JMP: + g.ip = rd16(); + break; + case OP_RND: { + u8 n = rd8(); + push((s16)((g.rng >> 4) % n)); + break; + } + case OP_POP: + pop(); + break; + default: + /* corrupt cue: halt scene */ + g.waiting = WAITING_FILM_DONE; + return; + } + } +} diff --git a/edge/runtime/debug.c b/edge/runtime/debug.c new file mode 100644 index 0000000..71f3260 --- /dev/null +++ b/edge/runtime/debug.c @@ -0,0 +1,35 @@ +/* edge/runtime/debug.c — fixed EWRAM debug block; the E2E contract. */ +#include "edge.h" + +#define D8(off) (*(vu8 *)(C_DEBUG_ADDR + (off))) +#define D16(off) (*(vu16 *)(C_DEBUG_ADDR + (off))) +#define D32(off) (*(vu32 *)(C_DEBUG_ADDR + (off))) + +void debug_flush(void) { + int i; + D32(DBGO_MAGIC) = DBG_MAGIC_VAL; + D8(DBGO_BOOTED) = 1; + D8(DBGO_SCENE) = g.scene; + D8(DBGO_WAITING) = g.waiting; + D8(DBGO_LAST_CHOICE) = (u8)g.last_choice; + D16(DBGO_FRAME) = g.frame; + D16(DBGO_CUE_IP) = g.ip; + D16(DBGO_CAM_X) = (u16)g.fx[TW_CAM_X]; + D16(DBGO_CUR_TEXT) = g.cur_text; + D16(DBGO_TWEEN_MASK) = g.tween_mask; + D8(DBGO_CAPTION_BUSY) = g.caption_busy; + D8(DBGO_FILM_DONE) = g.film_done; + for (i = 0; i < C_N_VARS; i++) D16(DBGO_VARS + i * 2) = (u16)g.vars[i]; + D16(DBGO_SPR0_X) = (u16)g.spr[0].x; + D16(DBGO_SPR0_Y) = (u16)g.spr[0].y; + D8(DBGO_PLAYER_CX) = g.pl_cx; + D8(DBGO_PLAYER_CY) = g.pl_cy; + D8(DBGO_PLAYER_DIR) = g.pl_dir; + D8(DBGO_BRICKS) = breakout_left(); + D8(DBGO_KIND) = g.sc ? g.sc->kind : 0; + D16(DBGO_ACT_X) = (u16)act.x; + D8(DBGO_ACT_ENEMIES) = act.active ? act.alive : 0; + D8(DBGO_ACT_BOSS_HP) = act.boss_hp; + D8(DBGO_ACT_SANDE) = act.sande_on; + D8(DBGO_MUSIC) = music_playing(); +} diff --git a/edge/runtime/edge.h b/edge/runtime/edge.h new file mode 100644 index 0000000..20a4436 --- /dev/null +++ b/edge/runtime/edge.h @@ -0,0 +1,373 @@ +/* edge/runtime/edge.h — internal contract of the @pocketjs/edge GBA runtime. + * + * The compiler residualizes a film into gen_data.c: one EdgeFilm with per-scene + * palettes, tile sets, tilemaps, OBJ sheets, cue bytecode, gradient tables and + * a global text bank + glyph store. The runtime is fixed; it never parses + * containers — everything is typed arrays in ROM. + * + * Frame order (main.c): input -> vm_tick -> tween_step -> sprites/captions + * update -> fx_apply (registers+shadow) -> debug -> vblank (ISR: OAM DMA, + * scroll regs) -> caption_pump (VRAM glyph streaming). + */ +#ifndef EDGE_H +#define EDGE_H +#include "gba.h" +#include "edge_gen.h" + +/* --- residual data (gen_data.c) ---------------------------------------------- */ +typedef struct { + u16 tile_base; /* in 4bpp-tile units within the scene OBJ sheet */ + u8 w, h; /* pixels: 8,16,32,64 */ + u8 frames; + u8 palbank; + u8 fps; /* default anim speed, frames per cell */ + u8 walk_fpd; /* walker sheet: frames per direction row (0 = plain sprite). + rows: DOWN, UP, SIDE (right = SIDE hflipped) */ +} EdgeProto; + +typedef struct { + u8 cx, cy, dir; + u8 slot; /* sprite slot the NPC lives in */ + u8 proto; /* sprite proto index */ + u8 cue; /* cue index run on A-interact (0xff none) */ + u8 solid; +} EdgeNpc; + +typedef struct { + u8 cx, cy, w, h; /* cell rect */ + u8 kind; /* C_TRIG_* */ + s16 value; /* EXIT: pushed as OP_WORLD result */ + u8 cue; /* EXAMINE/AUTO: cue index (0xff none) */ +} EdgeTrig; + +typedef struct { + u8 cols, rows; /* cells (16px) */ + const u8 *solid; /* cols*rows, 1 = blocked */ + u8 start_cx, start_cy, start_dir; + u8 player_slot; /* sprite slot the player walker lives in */ + u8 player_proto; /* walker proto index */ + const EdgeNpc *npcs; + u8 n_npcs; + const EdgeTrig *trigs; + u8 n_trigs; +} EdgeWorld; + +/* --- action stages ------------------------------------------------------------ */ +typedef struct { + s16 x; /* spawn world x (activates when the player closes within ~260px) */ + u8 proto; /* enemy sprite proto (4-frame strip, EF_*) */ + u8 behavior;/* C_EB_* */ + u8 hp; + u8 wave; /* gate tag */ +} EdgeSpawn; + +typedef struct { + s16 x, y; /* top surface */ + s16 w; +} EdgePlat; + +typedef struct { + s16 x; /* player x is held here... */ + u8 wave; /* ...while any spawned enemy of this wave lives */ +} EdgeGate; + +typedef struct { + u8 player_proto; /* 8-frame side-view strip, PF_* */ + u8 hp_max; + u8 sande_max; /* gauge units; 0 = no sandevistan in this stage */ + u8 exit_kind; /* C_AEXIT_* */ + s16 ground_y; /* ground surface, world px */ + s16 length; /* virtual stage length px (map wraps at 512) */ + u8 hp_var, sande_var, kills_var, deaths_var; /* bound game vars */ + const EdgeSpawn *spawns; + u8 n_spawns; + const EdgePlat *plats; + u8 n_plats; + const EdgeGate *gates; + u8 n_gates; + u8 boss; /* spawn index of the boss, 0xff none */ + u8 boss_phase_hp; /* boss hp <= this ends the stage with ACT_BOSS_PHASE */ + const u16 *pal_sande; /* 256: tinted BG palette while time is slowed */ +} EdgeStage; + +typedef struct { + const s8 *pcm; /* s8 mono @ C_MUSIC_RATE, u32-padded */ + u32 samples; + u8 loop; +} EdgeTrack; + +typedef struct { + const u16 *pal_bg; /* 256 */ + const u16 *pal_obj; /* 256 */ + const u8 *tiles_main; + u16 n_main; /* 4bpp tiles -> charblock 0.. */ + const u8 *tiles_shared; + u16 n_shared; /* -> charblock 2 @ C_FARSKY_BASE */ + const u16 *map_main; /* 32x32; 64x32 when map_sz 1; 64x64 (4 SBBs) when 2 */ + const u16 *map_far; /* 32x32 or 0 (map_sz < 2 only) */ + const u16 *map_sky; /* 32x32 or 0 (map_sz < 2 only) */ + u8 map_sz; /* 0 = 32x32, 1 = 64x32 wide pan, 2 = 64x64 world */ + s16 far_fac_q8, sky_fac_q8; /* parallax factors vs camera */ + s16 far_vx_q8, sky_vx_q8; /* autoscroll */ + const u16 *gradient; /* [160] backdrop per scanline, or 0 */ + const u8 *obj_tiles; + u16 obj_bytes; + const EdgeProto *protos; + u8 n_protos; + const u8 *cue; /* all cues, one blob */ + u16 cue_len; + const u16 *cue_offs; /* [n_cues] offsets; cue 0 = play */ + u8 n_cues; + u8 kind; /* C_SCENE_* */ + const EdgeWorld *world; /* kind == WORLD */ + const EdgeStage *stage; /* kind == ACTION */ + s16 cam0, cam_min, cam_max; + u8 raster_mode, raster_amp; + u8 letterbox0; + u16 backdrop; /* PAL[0] when no gradient */ +} EdgeScene; + +typedef struct { + const EdgeScene *scenes; + u8 n_scenes; + const u32 *text_offs; /* [n_texts] into text_blob */ + const u8 *text_blob; + u16 n_texts; + const u8 *glyphs; /* halfcells: 64 bytes each (two stacked 4bpp tiles) */ + u16 n_halfcells; + const u8 *ui_bg_tiles; /* 4 tiles: blank, box, accent, cursor */ + const u8 *ui_obj_tiles; /* A-prompt 16x16 + digits 0-9 8x16 */ + u16 ui_obj_bytes; + const EdgeTrack *tracks; /* PCM insert songs (0 when none) */ + u8 n_tracks; +} EdgeFilm; + +extern const EdgeFilm film; + +/* --- runtime state ------------------------------------------------------------ */ +typedef struct { + u8 active, proto, flags, mode; /* mode: 0 static, 1 loop */ + s16 x, y; /* world px (or screen px when SPR_SCREEN) */ + u8 frame, timer, fps; + u8 affine; /* uses matrix 0, double-size */ +} Spr; + +typedef struct { + u8 active, target, ease; + s16 from, to; + u16 t, T; +} Tween; + +typedef struct { + const EdgeScene *sc; + u8 scene; + u8 pending_scene; /* 0xff none */ + u16 frame; + u8 film_done; + u8 raster_mode; /* current RASTER_* (scene default, RASTER op overrides) */ + + /* cue vm */ + u16 ip; + s16 stack[8]; + u8 sp; + u8 waiting; /* WAITING_* */ + u16 wait_frames; + u8 fade_mode; /* FADE op in flight */ + u8 ctl_slot; + s16 ctl_exit; + u8 ctl_speed_q4; + u8 mash_var; + u16 mash_target; + s8 last_choice; + + /* choice ui */ + u8 choice_n, choice_cursor; + u16 choice_ids[C_MAX_CHOICES]; + + /* world mode (top-down grid) */ + u8 pl_cx, pl_cy, pl_dir; + u8 pl_step; /* frames left in current 16px step (0 = idle) */ + s8 pl_dx, pl_dy; + u8 anim_phase; /* walk animation phase counter */ + u8 in_sub; /* running an NPC/trigger cue from free roam */ + u16 ret_ip; /* not used to jump — roam resumes at the in-flight WORLD op */ + u16 trig_seen; /* TRIG_AUTO one-shot mask */ + s16 cam_max_x, cam_max_y; + + /* scripted walk (OP_WALK) */ + u8 wk_active, wk_slot; + s16 wk_tx, wk_ty; /* target px (sprite top-left) */ + + /* meters */ + u8 meter_on[2], meter_var[2], meter_max[2]; + s16 meter_x[2], meter_y[2]; + + /* fx state */ + s16 fx[16]; /* tween-target values, TW_* indexed */ + s32 far_off_q8, sky_off_q8; + Spr spr[C_MAX_SPRITES]; + u16 tween_mask; + Tween tw[C_MAX_TWEENS]; + + /* counter hud */ + u8 counter_show, counter_var; + s16 counter_x, counter_y; + s16 counter_prev; + u8 counter_bounce; + + /* text */ + u16 slot_next; + u16 cur_text; + u8 caption_busy; + u8 prompt_on; /* A-prompt blink master switch */ + + s16 vars[C_N_VARS]; + u16 flags; + + u16 keys, keys_prev; + u16 rng; +} Edge; + +/* --- action-mode state (action.c; exposed for debug_flush) --------------------- */ +typedef struct { + u8 active, behavior, proto, hp, wave, spawn_idx; + u8 face; /* 1 = facing left */ + u8 timer; /* behavior clock */ + u8 tele; /* telegraph frames (boss charge) */ + u8 anim; + s16 x, y; /* feet center, world px */ + s16 vx_q4, vy_q4; + s16 home_y; /* drone hover base */ +} ActEnemy; + +typedef struct { + u8 active, from_enemy, tile; + s16 x, y; + s16 vx_q4, vy_q4; +} ActBullet; + +typedef struct { + u8 active; + const EdgeStage *st; + u8 pl_slot; /* Spr slot the player actor occupies */ + s16 x, y; /* player feet center, world px */ + s16 vx_q4, vy_q4; + s32 x_q4, y_q4; + u8 grounded, face_left, shoot_cd, iframes, hurt_timer; + u8 sande_on; /* engaged this frame */ + u16 sande_frames; /* drain/regen clocks */ + u8 world_tick; /* increments only on world-update frames */ + u16 frame; + s16 checkpoint_x; + u8 next_spawn; /* spawns are x-sorted; index of first not yet activated */ + ActEnemy en[C_MAX_ENEMIES]; + ActBullet bl[C_MAX_BULLETS]; + u8 alive; /* enemies alive */ + u8 spawned_dead; /* count of spawns fully resolved (for AEXIT_CLEAR) */ + u8 boss_hp; + u8 done; /* result pushed */ + /* sandevistan afterimages: ring of recent player poses */ + s16 gx[C_ACT_GHOSTS], gy[C_ACT_GHOSTS]; + u8 gframe[C_ACT_GHOSTS], gface[C_ACT_GHOSTS], ghead; +} Act; + +extern Act act; + +extern Edge g; +extern ObjAttr oam_shadow[128]; + +/* values the ISR consumes (computed in fx_apply) */ +extern volatile u32 vbl_count; +extern u16 isr_hofs[4], isr_vofs[4]; /* final per-BG scroll incl. shake */ +extern u16 isr_lb; /* letterbox px */ +extern const u16 *isr_grad; /* 0 = none */ +extern u16 isr_backdrop; +extern u16 isr_wave_amp; /* px, 0 = off */ +extern u8 isr_wave_bg; /* which BG the wave applies to */ +extern u16 isr_wave_phase; + +/* input.c */ +void input_poll(void); +u16 key_held(u16 m); +u16 key_pressed(u16 m); + +/* irq.c */ +void irq_init(void); +void frame_wait(void); + +/* video.c */ +void video_boot(void); +void scene_load(u8 id); + +/* tween.c */ +void tween_start(u8 target, s16 to, u16 T, u8 ease); +void tween_step(void); +s16 *tween_slot_value(u8 target); /* where a target's value lives */ + +/* fx.c */ +void fx_reset(void); +void fx_apply(void); + +/* obj.c */ +void sprites_update(void); +void sprites_draw(void); /* fills oam_shadow */ +s16 spr_screen_x(const Spr *s); + +/* caption.c */ +void caption_boot(void); +void caption_show(u8 style, u16 text_id); +void caption_clear(u8 style); +void caption_dialog(u16 speaker, u16 body); +void caption_update(void); /* typewriter progress, 1 halfcell/frame */ +u8 caption_typing(void); +void choice_show(u8 n, const u16 *ids); +void choice_update(void); +u8 choice_done(s8 *out); + +/* cue_vm.c */ +void vm_start(void); +void vm_tick(void); +void vm_push(s16 v); +void vm_run_sub(u8 cue); /* jump into an NPC/trigger cue from free roam */ + +/* world.c */ +void world_enter(void); /* OP_WORLD executed: place player, camera, roam on */ +void world_service(void); /* one frame of free roam (WAITING_WORLD) */ +void world_warp(u8 cx, u8 cy, u8 dir); +void world_face(u8 slot, u8 dir); +void walk_start(u8 slot, u8 cx, u8 cy); +u8 walk_service(void); /* 1 while still walking */ + +/* breakout.c */ +void breakout_start(u8 rows, u8 lives, u16 budget); +u8 breakout_service(void); /* 1 while running; pushes result when done */ +void breakout_draw(void); /* fills its OAM slots (bricks/ball/paddle) */ +u8 breakout_left(void); /* bricks remaining (debug) */ + +/* action.c */ +void action_start(void); /* OP_ACTION executed on the scene's stage */ +void action_service(void); /* one frame (WAITING_ACTION) */ +void action_draw(void); /* enemies/bullets/ghosts into oam_shadow */ + +/* audio.c */ +void music_boot(void); +void music_play(u8 id); +void music_stop(void); +void music_service(void); /* per-frame stream clock: loop / end */ +u8 music_playing(void); /* track + 1, 0 = silent (debug) */ + +/* obj.c (meters live with the other HUD OBJs) */ +void meters_draw(void); + +/* sfx.c */ +void sfx_boot(void); +void sfx_play(u8 id); + +/* debug.c */ +void debug_flush(void); + +#define FLAG_GET(i) ((g.flags >> (i)) & 1) +#define FLAG_SET(i) (g.flags |= (u16)(1 << (i))) +#define FLAG_CLR(i) (g.flags &= (u16)~(1 << (i))) + +#endif diff --git a/edge/runtime/edge_gen.h b/edge/runtime/edge_gen.h new file mode 100644 index 0000000..850b6f0 --- /dev/null +++ b/edge/runtime/edge_gen.h @@ -0,0 +1,250 @@ +/* edge_gen.h — GENERATED from edge/spec/edge.ts. Do not edit. */ +#ifndef EDGE_GEN_H +#define EDGE_GEN_H +#define C_ACT_BOSS_PHASE 2 +#define C_ACT_BULLET_VX 64 +#define C_ACT_CLEARED 1 +#define C_ACT_EBULLET_VX 28 +#define C_ACT_GHOSTS 3 +#define C_ACT_GRAVITY 4 +#define C_ACT_IFRAMES 60 +#define C_ACT_JUMP_VY -58 +#define C_ACT_MELEE_RANGE 20 +#define C_ACT_RUN_VX 24 +#define C_ACT_SANDE_DRAIN 4 +#define C_ACT_SANDE_REGEN 12 +#define C_ACT_SANDE_VX 34 +#define C_ACT_SHOOT_CD 12 +#define C_ACT_SLOW_DIV 3 +#define C_AEXIT_CLEAR 1 +#define C_AEXIT_END 0 +#define C_ASCII_HALF 95 +#define C_BRICK_COLS 12 +#define C_BRICK_ROWS_MAX 6 +#define C_CAP_CARD 2 +#define C_CAP_CHIP 0 +#define C_CAP_COLS 26 +#define C_CAP_DIALOG 3 +#define C_CAP_LINES 2 +#define C_CAP_SUB 1 +#define C_CBB_MAIN 0 +#define C_CBB_SHARED 2 +#define C_CELLS_H 20 +#define C_CELLS_W 30 +#define C_CELL_PX 16 +#define C_CMP_EQ 0 +#define C_CMP_GE 5 +#define C_CMP_GT 3 +#define C_CMP_LE 4 +#define C_CMP_LT 2 +#define C_CMP_NE 1 +#define C_COUNTER_DIGITS 6 +#define C_DBG_MAGIC 1162298437 +#define C_DEBUG_ADDR 33554432 +#define C_DIR_DOWN 0 +#define C_DIR_LEFT 2 +#define C_DIR_RIGHT 3 +#define C_DIR_UP 1 +#define C_EASE_IN 1 +#define C_EASE_INOUT 3 +#define C_EASE_LINEAR 0 +#define C_EASE_OUT 2 +#define C_EB_BOSS 4 +#define C_EB_DRONE 2 +#define C_EB_GUNNER 1 +#define C_EB_THUG 0 +#define C_EB_TURRET 3 +#define C_EF_ATTACK 3 +#define C_EF_IDLE 0 +#define C_EF_WALK0 1 +#define C_EF_WALK1 2 +#define C_ENEMY_FRAMES 4 +#define C_FADE_IN_BLACK 0 +#define C_FADE_IN_WHITE 2 +#define C_FADE_OUT_BLACK 1 +#define C_FADE_OUT_WHITE 3 +#define C_FARSKY_BASE 4 +#define C_FARSKY_MAX 316 +#define C_GLYPH_SLOTS 96 +#define C_GLYPH_SLOT_BASE 320 +#define C_MAIN_TILE_MAX 1024 +#define C_MAX_BULLETS 16 +#define C_MAX_CHOICES 5 +#define C_MAX_CUES 24 +#define C_MAX_ENEMIES 8 +#define C_MAX_GATES 6 +#define C_MAX_NPCS 8 +#define C_MAX_PLATS 8 +#define C_MAX_PROTOS 12 +#define C_MAX_SCENES 20 +#define C_MAX_SPAWNS 24 +#define C_MAX_SPRITES 16 +#define C_MAX_TRACKS 4 +#define C_MAX_TRIGS 12 +#define C_MAX_TWEENS 16 +#define C_METER_SEGS 8 +#define C_MUSIC_RATE 13379 +#define C_MUSIC_SPF 224 +#define C_MUSIC_TIMER 64282 +#define C_N_FLAGS 16 +#define C_N_VARS 32 +#define C_OAM_BALL 120 +#define C_OAM_BRICK 48 +#define C_OAM_BULLET 56 +#define C_OAM_COUNTER 16 +#define C_OAM_ENEMY 48 +#define C_OAM_GHOST 72 +#define C_OAM_METER 26 +#define C_OAM_PADDLE 121 +#define C_OAM_PROMPT 24 +#define C_OBJ_TILE_MAX 1024 +#define C_OBJ_UI_BASE 960 +#define C_PALBANK_FAR 2 +#define C_PALBANK_MAIN 3 +#define C_PALBANK_OBJ_UI 15 +#define C_PALBANK_SKY 1 +#define C_PALBANK_UI 15 +#define C_PF_HURT 7 +#define C_PF_IDLE 0 +#define C_PF_JUMP 5 +#define C_PF_RUN0 1 +#define C_PF_SHOOT 6 +#define C_PLAYER_FRAMES 8 +#define C_RASTER_GRADIENT 1 +#define C_RASTER_OFF 0 +#define C_RASTER_WAVE_FAR 3 +#define C_RASTER_WAVE_MAIN 2 +#define C_SBB_FAR 26 +#define C_SBB_MAIN 24 +#define C_SBB_SKY 27 +#define C_SBB_UI 28 +#define C_SCENE_ACTION 2 +#define C_SCENE_CINE 0 +#define C_SCENE_WORLD 1 +#define C_SCREEN_H 160 +#define C_SCREEN_W 240 +#define C_SFX_BLIP 0 +#define C_SFX_CONFIRM 1 +#define C_SFX_STAR 3 +#define C_SFX_WHOOSH 2 +#define C_SPR_BEHIND 4 +#define C_SPR_GHOST 8 +#define C_SPR_HFLIP 1 +#define C_SPR_SCREEN 2 +#define C_STEP_FRAMES 8 +#define C_TOK_END 0 +#define C_TOK_NL 10 +#define C_TRIG_AUTO 2 +#define C_TRIG_EXAMINE 1 +#define C_TRIG_EXIT 0 +#define C_TW_SPRITE_BASE 64 +#define C_T_BLANK 0 +#define C_T_BOX 1 +#define C_T_BOX_ACCENT 2 +#define C_T_CURSOR 3 +#define C_UIT_EBULLET 34 +#define C_UIT_PBULLET 33 +#define C_UIT_SHOCK 36 +#define C_UIT_SPARK 35 +#define C_UI_ACCENT 3 +#define C_UI_BOX 2 +#define C_UI_INK 1 +#define C_UI_SHADOW 4 +#define C_WALK_ROW_DOWN 0 +#define C_WALK_ROW_SIDE 2 +#define C_WALK_ROW_UP 1 +#define C_WORLD_COLS_MAX 25 +#define C_WORLD_ROWS_MAX 25 +#define OP_END 0 +#define OP_WAIT 1 +#define OP_WAITA 2 +#define OP_WAIT_TWEENS 3 +#define OP_FADE 4 +#define OP_CAPTION 5 +#define OP_CAPTION_CLR 6 +#define OP_DIALOG 7 +#define OP_CHOICE 8 +#define OP_TWEEN 9 +#define OP_SPRITE_SHOW 10 +#define OP_SPRITE_HIDE 11 +#define OP_SPRITE_ANIM 12 +#define OP_SPRITE_MOVE 13 +#define OP_CONTROL 14 +#define OP_MASH 15 +#define OP_GOTO_SCENE 16 +#define OP_RASTER 17 +#define OP_SFX 18 +#define OP_COUNTER 19 +#define OP_AFFINE 20 +#define OP_LETTERBOX 21 +#define OP_WORLD 22 +#define OP_BREAKOUT 23 +#define OP_METER 24 +#define OP_WARP 25 +#define OP_FACE 26 +#define OP_WALK 27 +#define OP_ACTION 28 +#define OP_MUSIC 29 +#define OP_PUSH 32 +#define OP_SET_VAR 33 +#define OP_GET_VAR 34 +#define OP_ADD_VAR 35 +#define OP_SET_FLAG 36 +#define OP_CLR_FLAG 37 +#define OP_GET_FLAG 38 +#define OP_CMP 39 +#define OP_JZ 40 +#define OP_JMP 41 +#define OP_RND 42 +#define OP_POP 43 +#define TW_CAM_X 0 +#define TW_CAM_Y 1 +#define TW_BLDY 2 +#define TW_EVA 3 +#define TW_EVB 4 +#define TW_MOSAIC 5 +#define TW_WAVE_AMP 6 +#define TW_LETTERBOX 7 +#define TW_SHAKE 8 +#define TW_FAR_VX 9 +#define TW_SKY_VX 10 +#define TW_OBJ_SCALE 11 +#define TW_OBJ_ANGLE 12 +#define WAITING_RUN 0 +#define WAITING_A 1 +#define WAITING_DIALOG 2 +#define WAITING_CHOICE 3 +#define WAITING_CONTROL 4 +#define WAITING_MASH 5 +#define WAITING_FILM_DONE 6 +#define WAITING_BUSY 7 +#define WAITING_WORLD 8 +#define WAITING_MINIGAME 9 +#define WAITING_ACTION 10 +#define DBG_MAGIC_VAL 1162298437 +#define DBGO_MAGIC 0 +#define DBGO_BOOTED 4 +#define DBGO_SCENE 5 +#define DBGO_WAITING 6 +#define DBGO_LAST_CHOICE 7 +#define DBGO_FRAME 8 +#define DBGO_CUE_IP 10 +#define DBGO_CAM_X 12 +#define DBGO_CUR_TEXT 14 +#define DBGO_TWEEN_MASK 16 +#define DBGO_CAPTION_BUSY 18 +#define DBGO_FILM_DONE 19 +#define DBGO_VARS 20 +#define DBGO_SPR0_X 84 +#define DBGO_SPR0_Y 86 +#define DBGO_PLAYER_CX 88 +#define DBGO_PLAYER_CY 89 +#define DBGO_PLAYER_DIR 90 +#define DBGO_BRICKS 91 +#define DBGO_KIND 92 +#define DBGO_ACT_X 94 +#define DBGO_ACT_ENEMIES 96 +#define DBGO_ACT_BOSS_HP 97 +#define DBGO_ACT_SANDE 98 +#define DBGO_MUSIC 99 +#endif diff --git a/edge/runtime/fx.c b/edge/runtime/fx.c new file mode 100644 index 0000000..f7aa422 --- /dev/null +++ b/edge/runtime/fx.c @@ -0,0 +1,124 @@ +/* edge/runtime/fx.c — maps tween-target values onto GBA effect hardware: + * camera/parallax scroll, BLDCNT fades (black/white) and BG1 ghost alpha, + * mosaic, WIN0 letterbox (raster-assisted for the bars), screen shake, and + * OBJ affine matrix 0 (scale/angle). Runs once per frame after tween_step. */ +#include "edge.h" + +extern s8 sin8[256]; + +void fx_reset(void) { + int i; + for (i = 0; i < 16; i++) g.fx[i] = 0; + g.fx[TW_EVA] = 16; + g.fx[TW_EVB] = 0; + g.fx[TW_OBJ_SCALE] = 256; + g.far_off_q8 = 0; + g.sky_off_q8 = 0; + for (i = 0; i < C_MAX_TWEENS; i++) g.tw[i].active = 0; + g.tween_mask = 0; +} + +static s16 clamp16(s32 v, s32 lo, s32 hi) { + if (v < lo) return (s16)lo; + if (v > hi) return (s16)hi; + return (s16)v; +} + +void fx_apply(void) { + const EdgeScene *sc = g.sc; + s16 cam = g.fx[TW_CAM_X]; + s16 camy = g.fx[TW_CAM_Y]; + s16 shake = g.fx[TW_SHAKE]; + s16 sx = 0, sy = 0; + u16 dispcnt; + + if (shake > 0) { + sx = (s16)((g.rng >> 4) % (u16)(2 * shake + 1)) - shake; + sy = (s16)((g.rng >> 9) % (u16)(shake + 1)) - (shake >> 1); + } + + /* autoscroll accumulators */ + g.far_off_q8 += sc->far_vx_q8 + g.fx[TW_FAR_VX]; + g.sky_off_q8 += sc->sky_vx_q8 + g.fx[TW_SKY_VX]; + + isr_hofs[0] = 0; + isr_vofs[0] = 0; + isr_hofs[1] = (u16)(cam + sx); + isr_vofs[1] = (u16)(camy + sy); + isr_hofs[2] = (u16)((((s32)cam * sc->far_fac_q8) >> 8) + (s16)(g.far_off_q8 >> 8) + sx); + isr_vofs[2] = (u16)(((s32)camy * sc->far_fac_q8) >> 8); + isr_hofs[3] = (u16)((((s32)cam * sc->sky_fac_q8) >> 8) + (s16)(g.sky_off_q8 >> 8)); + isr_vofs[3] = 0; + + /* --- blending state machine ------------------------------------------------ */ + { + s16 y = g.fx[TW_BLDY]; + if (y < 0) y = 0; + if (y > 16) y = 16; + if (y > 0) { + REG_BLDCNT = (u16)(((g.fade_mode >= C_FADE_IN_WHITE) ? BLD_MODE_WHITE : BLD_MODE_BLACK) | BLD_ALL); + REG_BLDY = (u16)y; + } else if (g.fx[TW_EVA] != 16 || g.fx[TW_EVB] != 0) { + /* ghost/crossfade: BG1(+OBJ) over far/sky/backdrop */ + REG_BLDCNT = (u16)(BLD_MODE_ALPHA | BLD_BG1 | BLD_OBJ | BLD_2ND(BLD_BG2 | BLD_BG3 | BLD_BD)); + REG_BLDALPHA = (u16)((g.fx[TW_EVA] & 31) | ((g.fx[TW_EVB] & 31) << 8)); + REG_BLDY = 0; + } else { + /* idle: keep 2nd targets armed so SPR_GHOST OBJs still blend */ + REG_BLDCNT = (u16)(BLD_MODE_OFF | BLD_2ND(BLD_BG1 | BLD_BG2 | BLD_BG3 | BLD_BD)); + REG_BLDALPHA = (u16)(10 | (8 << 8)); + REG_BLDY = 0; + } + } + + /* --- mosaic ------------------------------------------------------------------ */ + { + u16 m = (u16)(g.fx[TW_MOSAIC] & 15); + REG_MOSAIC = (u16)(m | (m << 4) | (m << 8) | (m << 12)); + } + + /* --- letterbox (WIN0 band + ISR blackout outside) ----------------------------- */ + { + s16 lb = clamp16(g.fx[TW_LETTERBOX], 0, 48); + isr_lb = (u16)lb; + dispcnt = DCNT_MODE0 | DCNT_OBJ | DCNT_OBJ_1D | DCNT_BG0 | DCNT_BG1; + if (sc->map_far) dispcnt |= DCNT_BG2; + if (sc->map_sky) dispcnt |= DCNT_BG3; + if (lb > 0) { + REG_WIN0H = 240; + REG_WIN0V = (u16)((lb << 8) | (160 - lb)); + REG_WININ = WIN_ALL; + REG_WINOUT = WIN_BLD; /* backdrop only (ISR paints it black) */ + dispcnt |= DCNT_WIN0; + } + REG_DISPCNT = dispcnt; + } + + /* --- raster feed ---------------------------------------------------------------- */ + isr_backdrop = sc->backdrop; + isr_grad = (g.raster_mode == C_RASTER_GRADIENT && sc->gradient) ? sc->gradient : 0; + if (g.raster_mode == C_RASTER_WAVE_MAIN || g.raster_mode == C_RASTER_WAVE_FAR) { + isr_wave_bg = (g.raster_mode == C_RASTER_WAVE_MAIN) ? 1 : 2; + isr_wave_amp = (u16)(g.fx[TW_WAVE_AMP] < 0 ? 0 : g.fx[TW_WAVE_AMP]); + } else { + isr_wave_amp = 0; + } + + /* --- OBJ affine matrix 0 ----------------------------------------------------------- */ + { + s16 scale = g.fx[TW_OBJ_SCALE]; + u8 ang = (u8)g.fx[TW_OBJ_ANGLE]; + s32 c = sin8[(u8)(ang + 64)]; /* q7 cos */ + s32 s = sin8[ang]; + s32 inv; /* q8 of 1/scale */ + ObjAffine *m = &OAM_AFF[0]; + ObjAffine *ms = (ObjAffine *)oam_shadow; + if (scale < 16) scale = 16; + inv = 65536 / scale; + ms->pa = (s16)((c * inv) >> 7); + ms->pb = (s16)((-s * inv) >> 7); + ms->pc = (s16)((s * inv) >> 7); + ms->pd = (s16)((c * inv) >> 7); + (void)m; + } +} diff --git a/edge/runtime/gba.h b/edge/runtime/gba.h new file mode 100644 index 0000000..670f7ec --- /dev/null +++ b/edge/runtime/gba.h @@ -0,0 +1,196 @@ +/* edge/runtime/gba.h — GBA hardware definitions for the edge runtime. + * Fuller register set than aot's: blending, mosaic, windows, affine OBJ, + * HBlank/VBlank IRQs, PSG sound. Freestanding, per GBATEK/Tonc. */ +#ifndef EDGE_GBA_H +#define EDGE_GBA_H +#include + +typedef uint8_t u8; +typedef uint16_t u16; +typedef uint32_t u32; +typedef int8_t s8; +typedef int16_t s16; +typedef int32_t s32; +typedef volatile uint8_t vu8; +typedef volatile uint16_t vu16; +typedef volatile uint32_t vu32; + +#define MEM_EWRAM 0x02000000 +#define MEM_IWRAM 0x03000000 +#define MEM_IO 0x04000000 +#define MEM_PAL 0x05000000 +#define MEM_VRAM 0x06000000 +#define MEM_OAM 0x07000000 + +/* --- display ---------------------------------------------------------------- */ +#define REG_DISPCNT (*(vu16 *)(MEM_IO + 0x0000)) +#define REG_DISPSTAT (*(vu16 *)(MEM_IO + 0x0004)) +#define REG_VCOUNT (*(vu16 *)(MEM_IO + 0x0006)) +#define REG_BGCNT(n) (*(vu16 *)(MEM_IO + 0x0008 + (n) * 2)) +#define REG_BGHOFS(n) (*(vu16 *)(MEM_IO + 0x0010 + (n) * 4)) +#define REG_BGVOFS(n) (*(vu16 *)(MEM_IO + 0x0012 + (n) * 4)) + +#define DCNT_MODE0 0x0000 +#define DCNT_FORCE_BLANK 0x0080 +#define DCNT_BG0 0x0100 +#define DCNT_BG1 0x0200 +#define DCNT_BG2 0x0400 +#define DCNT_BG3 0x0800 +#define DCNT_OBJ 0x1000 +#define DCNT_WIN0 0x2000 +#define DCNT_OBJ_1D 0x0040 + +#define DSTAT_VBL_IRQ 0x0008 +#define DSTAT_HBL_IRQ 0x0010 + +#define BG_4BPP 0x0000 +#define BG_MOSAIC 0x0040 +#define BG_PRIO(n) ((n) & 3) +#define BG_CBB(n) (((n) & 3) << 2) +#define BG_SBB(n) (((n) & 0x1f) << 8) +#define BG_SIZE_32x32 0x0000 +#define BG_SIZE_64x32 0x4000 +#define BG_SIZE_32x64 0x8000 +#define BG_SIZE_64x64 0xC000 + +/* --- effects ------------------------------------------------------------------ */ +#define REG_MOSAIC (*(vu16 *)(MEM_IO + 0x004c)) +#define REG_BLDCNT (*(vu16 *)(MEM_IO + 0x0050)) +#define REG_BLDALPHA (*(vu16 *)(MEM_IO + 0x0052)) +#define REG_BLDY (*(vu16 *)(MEM_IO + 0x0054)) + +#define BLD_BG0 0x0001 +#define BLD_BG1 0x0002 +#define BLD_BG2 0x0004 +#define BLD_BG3 0x0008 +#define BLD_OBJ 0x0010 +#define BLD_BD 0x0020 +#define BLD_ALL 0x003f +#define BLD_MODE_OFF 0x0000 +#define BLD_MODE_ALPHA 0x0040 +#define BLD_MODE_WHITE 0x0080 +#define BLD_MODE_BLACK 0x00c0 +#define BLD_2ND(t) ((t) << 8) + +/* --- windows -------------------------------------------------------------------- */ +#define REG_WIN0H (*(vu16 *)(MEM_IO + 0x0040)) +#define REG_WIN0V (*(vu16 *)(MEM_IO + 0x0044)) +#define REG_WININ (*(vu16 *)(MEM_IO + 0x0048)) +#define REG_WINOUT (*(vu16 *)(MEM_IO + 0x004a)) +#define WIN_BG0 0x01 +#define WIN_BG1 0x02 +#define WIN_BG2 0x04 +#define WIN_BG3 0x08 +#define WIN_OBJ 0x10 +#define WIN_BLD 0x20 +#define WIN_ALL 0x3f + +/* --- interrupts ----------------------------------------------------------------- */ +#define REG_IE (*(vu16 *)(MEM_IO + 0x0200)) +#define REG_IF (*(vu16 *)(MEM_IO + 0x0202)) +#define REG_IME (*(vu16 *)(MEM_IO + 0x0208)) +#define IRQ_VBLANK 0x0001 +#define IRQ_HBLANK 0x0002 +#define ISR_VECTOR (*(vu32 *)(0x03007ffc)) +#define BIOS_IF (*(vu16 *)(0x03007ff8)) + +/* --- input ------------------------------------------------------------------------ */ +#define REG_KEYINPUT (*(vu16 *)(MEM_IO + 0x0130)) +#define KEY_A 0x0001 +#define KEY_B 0x0002 +#define KEY_SELECT 0x0004 +#define KEY_START 0x0008 +#define KEY_RIGHT 0x0010 +#define KEY_LEFT 0x0020 +#define KEY_UP 0x0040 +#define KEY_DOWN 0x0080 +#define KEY_R 0x0100 +#define KEY_L 0x0200 +#define KEY_MASK 0x03ff + +/* --- PSG sound -------------------------------------------------------------------- */ +#define REG_SOUNDCNT_L (*(vu16 *)(MEM_IO + 0x0080)) +#define REG_SOUNDCNT_H (*(vu16 *)(MEM_IO + 0x0082)) +#define REG_SOUNDCNT_X (*(vu16 *)(MEM_IO + 0x0084)) +#define REG_SOUND1CNT_L (*(vu16 *)(MEM_IO + 0x0060)) /* sweep */ +#define REG_SOUND1CNT_H (*(vu16 *)(MEM_IO + 0x0062)) /* duty/len/env */ +#define REG_SOUND1CNT_X (*(vu16 *)(MEM_IO + 0x0064)) /* freq/ctrl */ +#define REG_SOUND4CNT_L (*(vu16 *)(MEM_IO + 0x0078)) /* noise len/env */ +#define REG_SOUND4CNT_H (*(vu16 *)(MEM_IO + 0x007c)) /* noise freq/ctrl */ + +/* --- VRAM / palettes ----------------------------------------------------------------- */ +#define CHARBLOCK(n) ((u16 *)(MEM_VRAM + (n) * 0x4000)) +#define SCREENBLOCK(n) ((u16 *)(MEM_VRAM + (n) * 0x800)) +#define OBJ_VRAM ((u16 *)(MEM_VRAM + 0x10000)) +#define BG_PAL ((vu16 *)MEM_PAL) +#define OBJ_PAL ((vu16 *)(MEM_PAL + 0x200)) +#define SE(tile, palbank) (((tile) & 0x3ff) | (((palbank) & 0xf) << 12)) +#define SE_HFLIP 0x0400 +#define SE_VFLIP 0x0800 + +/* --- OAM ------------------------------------------------------------------------------- */ +typedef struct { + u16 attr0, attr1, attr2, fill; +} ObjAttr; +typedef struct { + u16 f0[3]; + s16 pa; + u16 f1[3]; + s16 pb; + u16 f2[3]; + s16 pc; + u16 f3[3]; + s16 pd; +} ObjAffine; +#define OAM_MEM ((ObjAttr *)MEM_OAM) +#define OAM_AFF ((ObjAffine *)MEM_OAM) + +#define ATTR0_Y(y) ((y) & 0xff) +#define ATTR0_AFFINE 0x0100 +#define ATTR0_HIDE 0x0200 +#define ATTR0_AFF_DBL 0x0300 +#define ATTR0_BLEND 0x0400 +#define ATTR0_MOSAIC 0x1000 +#define ATTR0_SQUARE 0x0000 +#define ATTR0_WIDE 0x4000 +#define ATTR0_TALL 0x8000 +#define ATTR1_X(x) ((x) & 0x1ff) +#define ATTR1_AFF(n) (((n) & 31) << 9) +#define ATTR1_HFLIP 0x1000 +#define ATTR1_VFLIP 0x2000 +#define ATTR1_SIZE(n) (((n) & 3) << 14) +#define ATTR2_TILE(t) ((t) & 0x3ff) +#define ATTR2_PRIO(p) (((p) & 3) << 10) +#define ATTR2_PALBANK(n) (((n) & 0xf) << 12) + +/* --- DMA3 -------------------------------------------------------------------------------- */ +#define REG_DMA3SAD (*(vu32 *)(MEM_IO + 0x00d4)) +#define REG_DMA3DAD (*(vu32 *)(MEM_IO + 0x00d8)) +#define REG_DMA3CNT (*(vu32 *)(MEM_IO + 0x00dc)) +#define DMA_ENABLE 0x80000000 +#define DMA_32 0x04000000 +#define DMA_REPEAT 0x02000000 +#define DMA_DST_FIXED 0x00400000 +#define DMA_START_SPECIAL 0x30000000 + +/* --- DirectSound A (FIFO) + Timer 0: PCM music streaming --------------------------------- */ +#define REG_FIFO_A (*(vu32 *)(MEM_IO + 0x00a0)) +#define REG_DMA1SAD (*(vu32 *)(MEM_IO + 0x00bc)) +#define REG_DMA1DAD (*(vu32 *)(MEM_IO + 0x00c0)) +#define REG_DMA1CNT (*(vu32 *)(MEM_IO + 0x00c4)) +#define REG_TM0CNT_L (*(vu16 *)(MEM_IO + 0x0100)) +#define REG_TM0CNT_H (*(vu16 *)(MEM_IO + 0x0102)) +#define TM_ENABLE 0x0080 +/* SOUNDCNT_H: PSG 100% | DSA vol 100% | DSA L+R | DSA timer 0 */ +#define SND_DSA_ON 0x0306 +#define SND_DSA_RESET 0x0800 + +static inline void dma3_copy32(volatile void *dst, const void *src, u32 words) { + REG_DMA3SAD = (u32)src; + REG_DMA3DAD = (u32)dst; + REG_DMA3CNT = words | DMA_ENABLE | DMA_32; +} + +#define IWRAM_CODE __attribute__((section(".iwram.text"), long_call, target("arm"))) + +#endif diff --git a/edge/runtime/gba.ld b/edge/runtime/gba.ld new file mode 100644 index 0000000..77c82cb --- /dev/null +++ b/edge/runtime/gba.ld @@ -0,0 +1,44 @@ +/* edge/runtime/gba.ld — GBA link map for @pocketjs/edge. + .iwram.text (the HBlank ISR) is copied to IWRAM together with .data so the + per-scanline raster handler never pays ROM waitstates. EWRAM stays free for + the debug block at 0x02000000. */ +OUTPUT_FORMAT("elf32-littlearm") +OUTPUT_ARCH(arm) +ENTRY(_start) + +MEMORY { + rom (rx) : ORIGIN = 0x08000000, LENGTH = 32M + iwram (rwx) : ORIGIN = 0x03000000, LENGTH = 32K + ewram (rwx) : ORIGIN = 0x02000000, LENGTH = 256K +} + +SECTIONS { + .init : { + KEEP(*(.init)) + *(.text .text.*) + *(.rodata .rodata.*) + . = ALIGN(4); + } > rom + + __data_lma = .; + .data : AT (__data_lma) { + . = ALIGN(4); + __data_start = .; + *(.iwram .iwram.*) + . = ALIGN(4); + *(.data .data.*) + . = ALIGN(4); + __data_end = .; + } > iwram + + .bss (NOLOAD) : { + . = ALIGN(4); + __bss_start = .; + *(.bss .bss.*) + *(COMMON) + . = ALIGN(4); + __bss_end = .; + } > iwram + + /DISCARD/ : { *(.comment) *(.note*) *(.ARM.attributes) } +} diff --git a/edge/runtime/input.c b/edge/runtime/input.c new file mode 100644 index 0000000..9e86cdd --- /dev/null +++ b/edge/runtime/input.c @@ -0,0 +1,16 @@ +/* edge/runtime/input.c */ +#include "edge.h" + +void input_poll(void) { + g.keys_prev = g.keys; + g.keys = (u16)(~REG_KEYINPUT) & KEY_MASK; + g.rng = g.rng * 25173 + 13849; +} + +u16 key_held(u16 m) { + return g.keys & m; +} + +u16 key_pressed(u16 m) { + return g.keys & ~g.keys_prev & m; +} diff --git a/edge/runtime/irq.c b/edge/runtime/irq.c new file mode 100644 index 0000000..32c9b27 --- /dev/null +++ b/edge/runtime/irq.c @@ -0,0 +1,79 @@ +/* edge/runtime/irq.c — VBlank/HBlank interrupt service. + * + * The HBlank handler is the raster engine: per scanline it writes the backdrop + * color (sky gradients get ~160 shades from a 15-color-per-bank system; the + * letterbox forces the out-of-window band to black) and, when a wave effect is + * active, a sine offset into one BG's HOFS. It runs from IWRAM (.iwram.text, + * copied by crt0) so it fits comfortably inside the 272-cycle HBlank. + * + * The VBlank handler DMAs the OAM shadow, latches the frame's scroll values, + * preloads line 0's backdrop, and bumps the frame counter. */ +#include "edge.h" + +volatile u32 vbl_count; +u16 isr_hofs[4], isr_vofs[4]; +u16 isr_lb; +const u16 *isr_grad; +u16 isr_backdrop; +u16 isr_wave_amp; +u8 isr_wave_bg; +u16 isr_wave_phase; + +s8 sin8[256]; /* q7 sine, built at boot (quarter-wave parabola — FX grade) */ + +ObjAttr oam_shadow[128]; + +IWRAM_CODE static void master_isr(void) { + u16 f = REG_IF; + if (f & IRQ_HBLANK) { + u32 vc = REG_VCOUNT; + u32 line = (vc >= 227) ? 0 : vc + 1; /* the line about to be drawn */ + if (line < 160) { + u16 c; + if (isr_lb && (line < isr_lb || line >= 160u - isr_lb)) c = 0; + else if (isr_grad) c = isr_grad[line]; + else c = isr_backdrop; + BG_PAL[0] = c; + if (isr_wave_amp) { + u32 idx = (line * 3 + isr_wave_phase) & 255; + s32 s = sin8[idx]; + REG_BGHOFS(isr_wave_bg) = (u16)(isr_hofs[isr_wave_bg] + ((s * (s32)isr_wave_amp) >> 7)); + } + } + } + if (f & IRQ_VBLANK) { + int i; + dma3_copy32(OAM_MEM, oam_shadow, sizeof(oam_shadow) / 4); + for (i = 0; i < 4; i++) { + REG_BGHOFS(i) = isr_hofs[i]; + REG_BGVOFS(i) = isr_vofs[i]; + } + BG_PAL[0] = isr_lb ? 0 : (isr_grad ? isr_grad[0] : isr_backdrop); + isr_wave_phase += 2; + vbl_count++; + } + REG_IF = f; + BIOS_IF |= f; +} + +void irq_init(void) { + int i; + for (i = 0; i < 128; i++) { + /* parabola per quadrant: peaks ~127 at i=64 */ + s32 v = (i * (128 - i)) >> 5; + if (v > 127) v = 127; + sin8[i] = (s8)v; + sin8[128 + i] = (s8)-v; + } + REG_IME = 0; + ISR_VECTOR = (u32)master_isr; + REG_DISPSTAT = DSTAT_VBL_IRQ | DSTAT_HBL_IRQ; + REG_IE = IRQ_VBLANK | IRQ_HBLANK; + REG_IF = 0xffff; + REG_IME = 1; +} + +void frame_wait(void) { + u32 f = vbl_count; + while (vbl_count == f) {} +} diff --git a/edge/runtime/main.c b/edge/runtime/main.c new file mode 100644 index 0000000..b4ed4e2 --- /dev/null +++ b/edge/runtime/main.c @@ -0,0 +1,30 @@ +/* edge/runtime/main.c — fixed frame loop. */ +#include "edge.h" + +int main(void) { + video_boot(); + sfx_boot(); + music_boot(); + irq_init(); + scene_load(0); + debug_flush(); + + for (;;) { + input_poll(); + if (g.pending_scene != 0xff) scene_load(g.pending_scene); + vm_tick(); + tween_step(); + sprites_update(); + caption_update(); + fx_apply(); + sprites_draw(); + meters_draw(); + breakout_draw(); + action_draw(); + music_service(); + debug_flush(); + g.frame++; + frame_wait(); + } + return 0; +} diff --git a/edge/runtime/obj.c b/edge/runtime/obj.c new file mode 100644 index 0000000..c523cce --- /dev/null +++ b/edge/runtime/obj.c @@ -0,0 +1,146 @@ +/* edge/runtime/obj.c — scene sprites, the var-bound digit counter HUD, and the + * blinking A prompt. Everything draws into oam_shadow; the VBlank ISR DMAs it. */ +#include "edge.h" + +#define UI_TILE_PROMPT (C_OBJ_UI_BASE) +#define UI_TILE_DIGIT(d) (C_OBJ_UI_BASE + 4 + (d) * 2) +#define UI_TILE_METER_FULL (C_OBJ_UI_BASE + 24) +#define UI_TILE_METER_EMPTY (C_OBJ_UI_BASE + 25) + +/* attr0 shape / attr1 size for w x h */ +static u16 shape_bits(u8 w, u8 h) { + if (w == h) return ATTR0_SQUARE; + return (w > h) ? ATTR0_WIDE : ATTR0_TALL; +} +static u16 size_bits(u8 w, u8 h) { + u8 m = (w > h) ? w : h; + u8 n = (w > h) ? h : w; + if (w == h) return (u16)(w == 8 ? 0 : w == 16 ? 1 : w == 32 ? 2 : 3); + if (m == 16) return 0; /* 16x8 */ + if (m == 32 && n == 8) return 1; /* 32x8 */ + if (m == 32) return 2; /* 32x16 */ + return 3; /* 64x32 */ +} + +void sprites_update(void) { + int i; + for (i = 0; i < C_MAX_SPRITES; i++) { + Spr *s = &g.spr[i]; + const EdgeProto *p; + if (!s->active || s->mode != 1) continue; + p = &g.sc->protos[s->proto]; + if (p->frames <= 1) continue; + if (++s->timer >= s->fps) { + s->timer = 0; + s->frame++; + if (s->frame >= p->frames) s->frame = 0; + } + } + if (g.counter_bounce) g.counter_bounce--; +} + +s16 spr_screen_x(const Spr *s) { + return (s->flags & C_SPR_SCREEN) ? s->x : (s16)(s->x - g.fx[TW_CAM_X]); +} + +void sprites_draw(void) { + int i; + u16 mos = (g.fx[TW_MOSAIC] > 0) ? ATTR0_MOSAIC : 0; + + for (i = 0; i < C_MAX_SPRITES; i++) { + Spr *s = &g.spr[i]; + ObjAttr *o = &oam_shadow[i]; + const EdgeProto *p; + s16 sx, sy; + if (!s->active) { + o->attr0 = ATTR0_HIDE; + continue; + } + p = &g.sc->protos[s->proto]; + sx = spr_screen_x(s); + sy = (s->flags & C_SPR_SCREEN) ? s->y : (s16)(s->y - g.fx[TW_CAM_Y]); + if (sx <= -(s16)p->w * 2 || sx >= 240 + p->w || sy <= -(s16)p->h * 2 || sy >= 160 + p->h) { + o->attr0 = ATTR0_HIDE; + continue; + } + if (s->affine) { + /* matrix 0, double-size render area, centered on (x,y) */ + o->attr0 = (u16)(ATTR0_Y(sy - p->h) | ATTR0_AFF_DBL | shape_bits(p->w, p->h) | mos | + ((s->flags & C_SPR_GHOST) ? ATTR0_BLEND : 0)); + o->attr1 = (u16)(ATTR1_X(sx - p->w) | ATTR1_AFF(0) | (size_bits(p->w, p->h) << 14)); + } else { + o->attr0 = (u16)(ATTR0_Y(sy) | shape_bits(p->w, p->h) | mos | + ((s->flags & C_SPR_GHOST) ? ATTR0_BLEND : 0)); + o->attr1 = (u16)(ATTR1_X(sx) | ((s->flags & C_SPR_HFLIP) ? ATTR1_HFLIP : 0) | + (size_bits(p->w, p->h) << 14)); + } + o->attr2 = (u16)(ATTR2_TILE(p->tile_base + s->frame * ((p->w / 8) * (p->h / 8))) | + ATTR2_PRIO((s->flags & C_SPR_BEHIND) ? 3 : 1) | ATTR2_PALBANK(p->palbank)); + } + + /* counter HUD: right-aligned digits bound to a var */ + { + s16 v = g.counter_show ? g.vars[g.counter_var] : -1; + int d; + if (g.counter_show && v != g.counter_prev) { + g.counter_bounce = 8; + g.counter_prev = v; + } + for (d = 0; d < C_COUNTER_DIGITS; d++) { + ObjAttr *o = &oam_shadow[C_OAM_COUNTER + d]; + if (!g.counter_show || v < 0) { + o->attr0 = ATTR0_HIDE; + continue; + } + { + s16 dx = (s16)(g.counter_x - d * 9); + s16 dy = (s16)(g.counter_y - ((g.counter_bounce > 4) ? (g.counter_bounce - 4) : 0)); + u16 digit = (u16)(v % 10); + o->attr0 = (u16)(ATTR0_Y(dy) | ATTR0_TALL); + o->attr1 = (u16)(ATTR1_X(dx) | (0 << 14)); /* 8x16 */ + o->attr2 = (u16)(ATTR2_TILE(UI_TILE_DIGIT(digit)) | ATTR2_PRIO(0) | ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + v /= 10; + if (v == 0 && d + 1 < C_COUNTER_DIGITS) { + int k; + for (k = d + 1; k < C_COUNTER_DIGITS; k++) oam_shadow[C_OAM_COUNTER + k].attr0 = ATTR0_HIDE; + break; + } + } + } + } + + /* blinking A prompt */ + { + ObjAttr *o = &oam_shadow[C_OAM_PROMPT]; + u8 blink = (g.frame & 31) < 22; + if (g.prompt_on && blink) { + o->attr0 = (u16)(ATTR0_Y(142) | ATTR0_SQUARE); + o->attr1 = (u16)(ATTR1_X(220) | (1 << 14)); /* 16x16 */ + o->attr2 = (u16)(ATTR2_TILE(UI_TILE_PROMPT) | ATTR2_PRIO(0) | ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + } else { + o->attr0 = ATTR0_HIDE; + } + } +} + +/* encounter meters: var-bound segment bars (conviction/resolve) */ +void meters_draw(void) { + int m, i; + for (m = 0; m < 2; m++) { + s16 v; + s16 filled; + for (i = 0; i < C_METER_SEGS; i++) oam_shadow[C_OAM_METER + m * C_METER_SEGS + i].attr0 = ATTR0_HIDE; + if (!g.meter_on[m]) continue; + v = g.vars[g.meter_var[m]]; + if (v < 0) v = 0; + if (v > g.meter_max[m]) v = g.meter_max[m]; + filled = (s16)((v * C_METER_SEGS + g.meter_max[m] - 1) / g.meter_max[m]); /* ceil */ + for (i = 0; i < C_METER_SEGS; i++) { + ObjAttr *o = &oam_shadow[C_OAM_METER + m * C_METER_SEGS + i]; + o->attr0 = (u16)(ATTR0_Y(g.meter_y[m]) | ATTR0_SQUARE); + o->attr1 = (u16)(ATTR1_X(g.meter_x[m] + i * 8) | (0 << 14)); /* 8x8 */ + o->attr2 = (u16)(ATTR2_TILE(i < filled ? UI_TILE_METER_FULL : UI_TILE_METER_EMPTY) | + ATTR2_PRIO(0) | ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + } + } +} diff --git a/edge/runtime/sfx.c b/edge/runtime/sfx.c new file mode 100644 index 0000000..e5e88c7 --- /dev/null +++ b/edge/runtime/sfx.c @@ -0,0 +1,33 @@ +/* edge/runtime/sfx.c — PSG micro-sfx: typewriter blip, confirm, whoosh, star. + * Channel 1 (square w/ sweep) + channel 4 (noise). Deliberately tiny. */ +#include "edge.h" + +void sfx_boot(void) { + REG_SOUNDCNT_X = 0x0080; /* master enable */ + REG_SOUNDCNT_L = 0xff77; /* all PSG channels L+R, max PSG volume */ + REG_SOUNDCNT_H = 0x0002; /* PSG full mix */ +} + +void sfx_play(u8 id) { + switch (id) { + case C_SFX_BLIP: /* short mid square tick */ + REG_SOUND1CNT_L = 0x0000; + REG_SOUND1CNT_H = 0x4140 | (3 << 12); /* duty 25%, env dec fast, quiet */ + REG_SOUND1CNT_X = 0x8000 | 1848; /* ~1.3 kHz */ + break; + case C_SFX_CONFIRM: /* brighter, longer */ + REG_SOUND1CNT_L = 0x0000; + REG_SOUND1CNT_H = 0x0180 | (10 << 12); /* duty 50%, slow decay */ + REG_SOUND1CNT_X = 0x8000 | 1985; /* ~2.1 kHz */ + break; + case C_SFX_WHOOSH: /* noise swell */ + REG_SOUND4CNT_L = (u16)(0x0300 | (9 << 12)); + REG_SOUND4CNT_H = 0x8000 | 0x0034; + break; + case C_SFX_STAR: /* rising sweep chirp */ + REG_SOUND1CNT_L = 0x0017; /* sweep up, shift 7 */ + REG_SOUND1CNT_H = 0x0180 | (9 << 12); + REG_SOUND1CNT_X = 0x8000 | 1750; + break; + } +} diff --git a/edge/runtime/tween.c b/edge/runtime/tween.c new file mode 100644 index 0000000..fbb1346 --- /dev/null +++ b/edge/runtime/tween.c @@ -0,0 +1,81 @@ +/* edge/runtime/tween.c — 16-slot property tween engine. + * Targets < 16 index g.fx[] (camera, blend, mosaic, wave, letterbox, shake, + * autoscroll, affine scale/angle). Targets >= C_TW_SPRITE_BASE address sprite + * slot x/y. One active tween per target; restarting replaces. */ +#include "edge.h" + +s16 *tween_slot_value(u8 target) { + if (target >= C_TW_SPRITE_BASE) { + u8 slot = (u8)((target - C_TW_SPRITE_BASE) >> 1); + if (slot >= C_MAX_SPRITES) return 0; + return (target & 1) ? &g.spr[slot].y : &g.spr[slot].x; + } + if (target < 16) return &g.fx[target]; + return 0; +} + +static u16 easef(u16 t, u16 T, u8 mode) { + u32 f = ((u32)t << 8) / T; + switch (mode) { + case C_EASE_IN: + f = (f * f) >> 8; + break; + case C_EASE_OUT: { + u32 inv = 256 - f; + f = 256 - ((inv * inv) >> 8); + break; + } + case C_EASE_INOUT: + f = ((u32)f * f * (768 - 2 * f)) >> 16; + break; + } + return (u16)f; +} + +void tween_start(u8 target, s16 to, u16 T, u8 ease) { + int i, free_i = -1; + s16 *v = tween_slot_value(target); + if (!v) return; + if (T == 0) { + *v = to; + return; + } + for (i = 0; i < C_MAX_TWEENS; i++) { + if (g.tw[i].active && g.tw[i].target == target) { + free_i = i; /* replace in place */ + break; + } + if (!g.tw[i].active && free_i < 0) free_i = i; + } + if (free_i < 0) return; /* out of slots: drop (compiler budget prevents this) */ + g.tw[free_i].active = 1; + g.tw[free_i].target = target; + g.tw[free_i].ease = ease; + g.tw[free_i].from = *v; + g.tw[free_i].to = to; + g.tw[free_i].t = 0; + g.tw[free_i].T = T; +} + +void tween_step(void) { + int i; + u16 mask = 0; + for (i = 0; i < C_MAX_TWEENS; i++) { + Tween *tw = &g.tw[i]; + s16 *v; + if (!tw->active) continue; + v = tween_slot_value(tw->target); + tw->t++; + if (tw->t >= tw->T) { + *v = tw->to; + tw->active = 0; + continue; + } + { + u16 f = easef(tw->t, tw->T, tw->ease); + *v = (s16)(tw->from + (((s32)(tw->to - tw->from) * f) >> 8)); + } + mask |= (u16)(1u << i); + } + g.tween_mask = mask; +} diff --git a/edge/runtime/video.c b/edge/runtime/video.c new file mode 100644 index 0000000..bd2095c --- /dev/null +++ b/edge/runtime/video.c @@ -0,0 +1,147 @@ +/* edge/runtime/video.c — boot video state + whole-scene VRAM loads. + * Scene loads happen behind force-blank (the author fades/mosaics out first), + * so we can DMA palettes, three tile sets, four tilemaps and the OBJ sheets + * in one go without fighting the PPU. */ +#include "edge.h" + +Edge g; + +static void bgcnt_setup(u8 map_sz) { + u16 sz = map_sz == 2 ? BG_SIZE_64x64 : map_sz == 1 ? BG_SIZE_64x32 : BG_SIZE_32x32; + REG_BGCNT(0) = BG_4BPP | BG_PRIO(0) | BG_CBB(C_CBB_SHARED) | BG_SBB(C_SBB_UI) | BG_SIZE_32x32; + REG_BGCNT(1) = BG_4BPP | BG_PRIO(2) | BG_CBB(C_CBB_MAIN) | BG_SBB(C_SBB_MAIN) | BG_MOSAIC | sz; + REG_BGCNT(2) = BG_4BPP | BG_PRIO(3) | BG_CBB(C_CBB_SHARED) | BG_SBB(C_SBB_FAR) | BG_MOSAIC | BG_SIZE_32x32; + REG_BGCNT(3) = BG_4BPP | BG_PRIO(3) | BG_CBB(C_CBB_SHARED) | BG_SBB(C_SBB_SKY) | BG_SIZE_32x32; +} + +void video_boot(void) { + REG_DISPCNT = DCNT_FORCE_BLANK; + /* fixed UI BG tiles at shared-charblock 0..3 (blank/box/accent/cursor) */ + dma3_copy32(CHARBLOCK(C_CBB_SHARED), film.ui_bg_tiles, (4 * 32) / 4); + /* built-in UI OBJ sheet (A prompt + digits + meter/breakout) at the top */ + dma3_copy32(OBJ_VRAM + C_OBJ_UI_BASE * 16, film.ui_obj_tiles, film.ui_obj_bytes / 4); + bgcnt_setup(0); + g.pending_scene = 0xff; + g.last_choice = -1; + g.rng = 0xbeef; +} + +static void fill_map(u16 *sb, u16 se, u32 count) { + u32 i; + u32 v = se | ((u32)se << 16); + for (i = 0; i < count / 2; i++) ((u32 *)sb)[i] = v; +} + +void scene_load(u8 id) { + const EdgeScene *sc = &film.scenes[id]; + int i; + + REG_DISPCNT = DCNT_FORCE_BLANK; + g.scene = id; + g.sc = sc; + g.pending_scene = 0xff; + g.frame = 0; + + /* palettes (backdrop entry 0 is raster-owned; keep a copy) */ + dma3_copy32(BG_PAL, sc->pal_bg, 512 / 4); + dma3_copy32(OBJ_PAL, sc->pal_obj, 512 / 4); + + /* tiles */ + if (sc->n_main) dma3_copy32(CHARBLOCK(C_CBB_MAIN), sc->tiles_main, ((u32)sc->n_main * 32) / 4); + if (sc->n_shared) + dma3_copy32(CHARBLOCK(C_CBB_SHARED) + C_FARSKY_BASE * 16, sc->tiles_shared, ((u32)sc->n_shared * 32) / 4); + /* clear glyph slots */ + { + u32 *gz = (u32 *)(CHARBLOCK(C_CBB_SHARED) + C_GLYPH_SLOT_BASE * 16); + for (i = 0; i < (C_GLYPH_SLOTS * 2 * 32) / 4; i++) gz[i] = 0; + } + + /* maps. map_sz 2 = 64x64: four consecutive screenblocks starting at + * SBB_MAIN (TL,TR,BL,BR), which covers the far/sky blocks — no parallax + * layers in world scenes. */ + if (sc->map_sz == 2) { + dma3_copy32(SCREENBLOCK(C_SBB_MAIN), sc->map_main, 0x2000 / 4); + } else { + dma3_copy32(SCREENBLOCK(C_SBB_MAIN), sc->map_main, (sc->map_sz ? 0x1000u : 0x800u) / 4); + if (sc->map_far) dma3_copy32(SCREENBLOCK(C_SBB_FAR), sc->map_far, 0x800 / 4); + else fill_map(SCREENBLOCK(C_SBB_FAR), 0, 0x400); + if (sc->map_sky) dma3_copy32(SCREENBLOCK(C_SBB_SKY), sc->map_sky, 0x800 / 4); + else fill_map(SCREENBLOCK(C_SBB_SKY), 0, 0x400); + } + fill_map(SCREENBLOCK(C_SBB_UI), 0, 0x400); + + /* OBJ sheets (below the persistent UI sheet) */ + if (sc->obj_bytes) dma3_copy32(OBJ_VRAM, sc->obj_tiles, sc->obj_bytes / 4); + + bgcnt_setup(sc->map_sz); + + /* world + fx reset */ + fx_reset(); + g.fx[TW_CAM_X] = sc->cam0; + g.fx[TW_LETTERBOX] = sc->letterbox0; + g.fx[TW_BLDY] = 16; /* scenes wake up faded out; the cue's FADE reveals */ + g.raster_mode = sc->raster_mode; + if (sc->raster_mode == C_RASTER_WAVE_MAIN || sc->raster_mode == C_RASTER_WAVE_FAR) + g.fx[TW_WAVE_AMP] = sc->raster_amp; + + for (i = 0; i < C_MAX_SPRITES; i++) g.spr[i].active = 0; + for (i = 0; i < 128; i++) oam_shadow[i].attr0 = ATTR0_HIDE; + g.counter_show = 0; + g.counter_bounce = 0; + g.prompt_on = 0; + g.meter_on[0] = g.meter_on[1] = 0; + g.wk_active = 0; + g.in_sub = 0; + g.trig_seen = 0; + g.pl_step = 0; + g.cam_max_x = sc->cam_max; + g.cam_max_y = 0; + + /* world scenes wake up populated: player + NPCs stand on their cells so the + * author's fade-in reveals a living room, before OP_WORLD hands over input */ + if (sc->kind == C_SCENE_WORLD && sc->world) { + const EdgeWorld *w = sc->world; + g.cam_max_x = (s16)(w->cols * C_CELL_PX - 240); + g.cam_max_y = (s16)(w->rows * C_CELL_PX - 160); + if (g.cam_max_x < 0) g.cam_max_x = 0; + if (g.cam_max_y < 0) g.cam_max_y = 0; + { + Spr *s = &g.spr[w->player_slot]; + s->active = 1; + s->proto = w->player_proto; + s->mode = 0; + s->frame = 0; + s->timer = 0; + s->flags = 0; + s->affine = 0; + s->fps = sc->protos[s->proto].fps; + } + world_warp(w->start_cx, w->start_cy, w->start_dir); + for (i = 0; i < w->n_npcs; i++) { + const EdgeNpc *n = &w->npcs[i]; + Spr *s = &g.spr[n->slot]; + s->active = 1; + s->proto = n->proto; + s->mode = 0; + s->frame = 0; + s->timer = 0; + s->flags = 0; + s->affine = 0; + s->x = (s16)(n->cx * C_CELL_PX + (C_CELL_PX - sc->protos[s->proto].w) / 2); + s->y = (s16)((n->cy + 1) * C_CELL_PX - sc->protos[s->proto].h); + s->fps = sc->protos[s->proto].fps; + world_face(n->slot, n->dir); + } + } + + caption_boot(); + + /* cue vm */ + g.ip = 0; + g.sp = 0; + g.waiting = WAITING_RUN; + g.wait_frames = 0; + g.cur_text = 0; + + fx_apply(); /* also re-enables the display */ +} diff --git a/edge/runtime/world.c b/edge/runtime/world.c new file mode 100644 index 0000000..c36732a --- /dev/null +++ b/edge/runtime/world.c @@ -0,0 +1,274 @@ +/* edge/runtime/world.c — top-down grid world: Pokémon-style cell stepping, + * facing/interaction, exit/auto triggers, soft camera follow on both axes, + * and scripted grid walks for cutscenes. Active while the cue VM is parked + * in WAITING_WORLD (OP_WORLD) or during OP_WALK (WAITING_BUSY + wk_active). */ +#include "edge.h" + +static const s8 DX[4] = {0, 0, -1, 1}; /* DOWN, UP, LEFT, RIGHT */ +static const s8 DY[4] = {1, -1, 0, 0}; + +static const EdgeWorld *wld(void) { + return g.sc->world; +} + +/* place a sprite's top-left so its feet stand on cell (cx,cy) */ +static void place_on_cell(Spr *s, u8 cx, u8 cy) { + const EdgeProto *p = &g.sc->protos[s->proto]; + s->x = (s16)(cx * C_CELL_PX + (C_CELL_PX - p->w) / 2); + s->y = (s16)((cy + 1) * C_CELL_PX - p->h); +} + +/* facing for walker protos: rows DOWN/UP/SIDE, right = SIDE hflipped */ +void world_face(u8 slot, u8 dir) { + Spr *s = &g.spr[slot]; + const EdgeProto *p = &g.sc->protos[s->proto]; + u8 row; + if (!p->walk_fpd) return; + row = (dir == C_DIR_DOWN) ? C_WALK_ROW_DOWN : (dir == C_DIR_UP) ? C_WALK_ROW_UP : C_WALK_ROW_SIDE; + s->mode = 0; + s->frame = (u8)(row * p->walk_fpd); + if (dir == C_DIR_RIGHT) s->flags |= C_SPR_HFLIP; + else s->flags &= (u8)~C_SPR_HFLIP; + if (g.sc->world && slot == g.sc->world->player_slot) g.pl_dir = dir; +} + +static void camera_snap(void) { + const EdgeWorld *w = wld(); + Spr *s = &g.spr[w->player_slot]; + const EdgeProto *p = &g.sc->protos[s->proto]; + s16 tx = (s16)(s->x + p->w / 2 - 120); + s16 ty = (s16)(s->y + p->h / 2 - 80); + if (tx < 0) tx = 0; + if (tx > g.cam_max_x) tx = g.cam_max_x; + if (ty < 0) ty = 0; + if (ty > g.cam_max_y) ty = g.cam_max_y; + g.fx[TW_CAM_X] = tx; + g.fx[TW_CAM_Y] = ty; +} + +static void camera_follow(void) { + const EdgeWorld *w = wld(); + Spr *s = &g.spr[w->player_slot]; + const EdgeProto *p = &g.sc->protos[s->proto]; + s16 tx = (s16)(s->x + p->w / 2 - 120); + s16 ty = (s16)(s->y + p->h / 2 - 80); + if (tx < 0) tx = 0; + if (tx > g.cam_max_x) tx = g.cam_max_x; + if (ty < 0) ty = 0; + if (ty > g.cam_max_y) ty = g.cam_max_y; + g.fx[TW_CAM_X] += (s16)((tx - g.fx[TW_CAM_X]) >> 3); + g.fx[TW_CAM_Y] += (s16)((ty - g.fx[TW_CAM_Y]) >> 3); +} + +void world_warp(u8 cx, u8 cy, u8 dir) { + const EdgeWorld *w = wld(); + Spr *s; + if (!w) return; + s = &g.spr[w->player_slot]; + g.pl_cx = cx; + g.pl_cy = cy; + g.pl_step = 0; + place_on_cell(s, cx, cy); + world_face(w->player_slot, dir); + camera_snap(); +} + +static u8 npc_at(u8 cx, u8 cy, const EdgeNpc **out) { + const EdgeWorld *w = wld(); + int i; + for (i = 0; i < w->n_npcs; i++) { + if (w->npcs[i].cx == cx && w->npcs[i].cy == cy) { + if (out) *out = &w->npcs[i]; + return 1; + } + } + return 0; +} + +static const EdgeTrig *trig_at(u8 cx, u8 cy, u8 kind) { + const EdgeWorld *w = wld(); + int i; + for (i = 0; i < w->n_trigs; i++) { + const EdgeTrig *t = &w->trigs[i]; + if (t->kind == kind && cx >= t->cx && cx < t->cx + t->w && cy >= t->cy && cy < t->cy + t->h) + return t; + } + return 0; +} + +static u8 blocked(s16 cx, s16 cy) { + const EdgeWorld *w = wld(); + const EdgeNpc *n; + if (cx < 0 || cy < 0 || cx >= w->cols || cy >= w->rows) return 1; + if (w->solid[cy * w->cols + cx]) return 1; + if (npc_at((u8)cx, (u8)cy, &n) && n->solid) return 1; + return 0; +} + +/* walk-cycle frame while moving: fpd 1 -> waddle (hflip toggle on down/up), + * fpd 2 -> alternate, fpd 3 -> step-stand-step-stand, fpd 4+ -> full cycle */ +static void anim_step(u8 slot, u8 dir) { + Spr *s = &g.spr[slot]; + const EdgeProto *p = &g.sc->protos[s->proto]; + u8 row = (dir == C_DIR_DOWN) ? C_WALK_ROW_DOWN : (dir == C_DIR_UP) ? C_WALK_ROW_UP : C_WALK_ROW_SIDE; + u8 ph = (u8)((g.anim_phase >> 3) & 3); + if (!p->walk_fpd) return; + if (p->walk_fpd >= 4) { + s->frame = (u8)(row * p->walk_fpd + ((g.anim_phase >> 2) & 3)); + } else if (p->walk_fpd == 3) { + static const u8 pat[4] = {1, 0, 2, 0}; + s->frame = (u8)(row * p->walk_fpd + pat[ph]); + } else if (p->walk_fpd == 2) { + s->frame = (u8)(row * p->walk_fpd + (ph & 1)); + } else { + s->frame = (u8)(row); + if (dir == C_DIR_DOWN || dir == C_DIR_UP) { + if (ph & 1) s->flags |= C_SPR_HFLIP; + else s->flags &= (u8)~C_SPR_HFLIP; + } + } + if (dir == C_DIR_RIGHT) s->flags |= C_SPR_HFLIP; + else if (dir == C_DIR_LEFT) s->flags &= (u8)~C_SPR_HFLIP; +} + +/* one frame of a 16px step in flight; returns 1 when it just finished */ +static u8 step_advance(void) { + const EdgeWorld *w = wld(); + Spr *s = &g.spr[w->player_slot]; + s->x += g.pl_dx; + s->y += g.pl_dy; + g.anim_phase++; + anim_step(w->player_slot, g.pl_dir); + if (--g.pl_step == 0) { + g.pl_cx = (u8)((s16)g.pl_cx + DX[g.pl_dir]); + g.pl_cy = (u8)((s16)g.pl_cy + DY[g.pl_dir]); + place_on_cell(s, g.pl_cx, g.pl_cy); /* snap out drift */ + return 1; + } + return 0; +} + +void world_enter(void) { + if (!wld()) return; + g.waiting = WAITING_WORLD; +} + +void world_service(void) { + const EdgeWorld *w = wld(); + Spr *s; + if (!w) { + g.waiting = WAITING_RUN; + return; + } + s = &g.spr[w->player_slot]; + + if (g.pl_step) { + if (step_advance()) { + const EdgeTrig *t; + /* landed on a cell: exits end the roam, autos run their cue once */ + if ((t = trig_at(g.pl_cx, g.pl_cy, C_TRIG_EXIT)) != 0) { + world_face(w->player_slot, g.pl_dir); + vm_push(t->value); + g.waiting = WAITING_RUN; + return; + } + if ((t = trig_at(g.pl_cx, g.pl_cy, C_TRIG_AUTO)) != 0) { + u8 bit = (u8)(t - w->trigs); + if (!(g.trig_seen & (1u << bit))) { + g.trig_seen |= (u16)(1u << bit); + world_face(w->player_slot, g.pl_dir); + vm_run_sub(t->cue); + return; + } + } + } + } else { + /* idle on a cell */ + u8 dir = 0xff; + if (key_held(KEY_DOWN)) dir = C_DIR_DOWN; + else if (key_held(KEY_UP)) dir = C_DIR_UP; + else if (key_held(KEY_LEFT)) dir = C_DIR_LEFT; + else if (key_held(KEY_RIGHT)) dir = C_DIR_RIGHT; + + if (key_pressed(KEY_A)) { + s16 fx = (s16)g.pl_cx + DX[g.pl_dir]; + s16 fy = (s16)g.pl_cy + DY[g.pl_dir]; + const EdgeNpc *n; + const EdgeTrig *t; + if (fx >= 0 && fy >= 0 && fx < w->cols && fy < w->rows && npc_at((u8)fx, (u8)fy, &n) && + n->cue != 0xff) { + /* NPC turns to the player */ + static const u8 OPP[4] = {C_DIR_UP, C_DIR_DOWN, C_DIR_RIGHT, C_DIR_LEFT}; + world_face(n->slot, OPP[g.pl_dir]); + sfx_play(C_SFX_BLIP); + vm_run_sub(n->cue); + return; + } + if (fx >= 0 && fy >= 0 && (t = trig_at((u8)fx, (u8)fy, C_TRIG_EXAMINE)) != 0 && t->cue != 0xff) { + sfx_play(C_SFX_BLIP); + vm_run_sub(t->cue); + return; + } + } + + if (dir != 0xff) { + s16 tx = (s16)g.pl_cx + DX[dir]; + s16 ty = (s16)g.pl_cy + DY[dir]; + if (dir != g.pl_dir) world_face(w->player_slot, dir); + if (!blocked(tx, ty)) { + g.pl_step = C_STEP_FRAMES; + g.pl_dx = (s8)(DX[dir] * (C_CELL_PX / C_STEP_FRAMES)); + g.pl_dy = (s8)(DY[dir] * (C_CELL_PX / C_STEP_FRAMES)); + } else { + anim_step(w->player_slot, dir); /* bump-walk in place */ + g.anim_phase++; + } + } else { + /* stand */ + world_face(w->player_slot, g.pl_dir); + } + } + + camera_follow(); +} + +/* --- scripted walks (OP_WALK, any walker sprite, cutscenes) -------------------- */ +void walk_start(u8 slot, u8 cx, u8 cy) { + const EdgeProto *p = &g.sc->protos[g.spr[slot].proto]; + g.wk_active = 1; + g.wk_slot = slot; + g.wk_tx = (s16)(cx * C_CELL_PX + (C_CELL_PX - p->w) / 2); + g.wk_ty = (s16)((cy + 1) * C_CELL_PX - p->h); +} + +u8 walk_service(void) { + Spr *s = &g.spr[g.wk_slot]; + u8 dir; + s16 d; + if (!g.wk_active) return 0; + if (s->x != g.wk_tx) { + d = (s16)(g.wk_tx - s->x); + dir = d > 0 ? C_DIR_RIGHT : C_DIR_LEFT; + s->x += d > 0 ? (d > 2 ? 2 : d) : (d < -2 ? -2 : d); + } else if (s->y != g.wk_ty) { + d = (s16)(g.wk_ty - s->y); + dir = d > 0 ? C_DIR_DOWN : C_DIR_UP; + s->y += d > 0 ? (d > 2 ? 2 : d) : (d < -2 ? -2 : d); + } else { + const EdgeWorld *w = g.sc->world; + g.wk_active = 0; + if (w && g.wk_slot == w->player_slot) { + g.pl_cx = (u8)((s->x + g.sc->protos[s->proto].w / 2) / C_CELL_PX); + g.pl_cy = (u8)((s->y + g.sc->protos[s->proto].h - 1) / C_CELL_PX); + world_face(g.wk_slot, g.pl_dir); + camera_snap(); + } else { + world_face(g.wk_slot, C_DIR_DOWN); + } + return 0; + } + g.anim_phase++; + anim_step(g.wk_slot, dir); + if (g.sc->world && g.wk_slot == g.sc->world->player_slot) camera_follow(); + return 1; +} diff --git a/edge/spec/edge.ts b/edge/spec/edge.ts new file mode 100644 index 0000000..b8063ae --- /dev/null +++ b/edge/spec/edge.ts @@ -0,0 +1,388 @@ +// edge/spec/edge.ts — single source of truth for the @pocketjs/edge binary +// contract: cue bytecode, tween targets, VRAM/palette plan, debug block. +// +// @pocketjs/edge is the generalized descendant of @pocketjs/cine: a GBA-only +// interactive-biography DSL. It keeps cine's whole cinematic vocabulary +// (4-layer Mode-0 parallax, BLDCNT fades, HBlank raster FX, letterbox, affine, +// typewriter captions) and adds two playable modes: top-down WORLD scenes +// (Pokémon-style grid walking, NPCs, triggers) and set-piece minigames +// (Breakout) plus encounter UI (meters) — all driven by the same cue VM. +// +// Mirrored into C by spec/gen-c.ts -> runtime/edge_gen.h. + +// --- screen / layer plan ------------------------------------------------------ +export const SCREEN_W = 240; +export const SCREEN_H = 160; +export const CELLS_W = 30; +export const CELLS_H = 20; + +// Mode 0. Fixed semantic layers: +// BG0 = ui (captions/dialog/choice), prio 0, charbase 2, screenblock 28 +// BG1 = main stage, prio 2, charbase 0 (may spill into charblock 1; <=1024 tiles) +// BG2 = far parallax, prio 3, charbase 2, screenblock 26 +// BG3 = sky, prio 3 (drawn under far via BG order), charbase 2, screenblock 27 +export const CBB_MAIN = 0; +export const CBB_SHARED = 2; // ui + far + sky share charblock 2 (512 tiles) +export const SBB_MAIN = 24; // +25 when wide (64x32 map) +export const SBB_FAR = 26; +export const SBB_SKY = 27; +export const SBB_UI = 28; + +// Shared charblock 2 allocation (tile indices relative to charbase 2): +export const T_BLANK = 0; +export const T_BOX = 1; // caption/dialog box fill +export const T_BOX_ACCENT = 2; // vue-green underline tile +export const T_CURSOR = 3; // choice cursor (8x8 arrow) +export const FARSKY_BASE = 4; // far+sky art tiles, up to GLYPH_SLOT area +export const GLYPH_SLOT_BASE = 320; // 96 halfcell slots x 2 stacked tiles = 192 +export const GLYPH_SLOTS = 96; +export const FARSKY_MAX = GLYPH_SLOT_BASE - FARSKY_BASE; // 316 tiles + +export const MAIN_TILE_MAX = 1024; // charblocks 0+1 + +// BG palette banks: +export const PALBANK_SKY = 1; +export const PALBANK_FAR = 2; +export const PALBANK_MAIN = 3; +export const PALBANK_UI = 15; // 0 transparent, 1 ink, 2 box, 3 accent, 4 shadow +export const UI_INK = 1; +export const UI_BOX = 2; +export const UI_ACCENT = 3; +export const UI_SHADOW = 4; + +// OBJ: charblock 4-5 (1024 4bpp tiles), 1D mapping. Last palbank = built-in UI +// sheet (A-prompt 16x16 + digits 0-9 8x16 + meter segs + breakout court pieces) +// appended by the compiler at OBJ_UI_BASE. Scene sheets must stay below it. +export const OBJ_TILE_MAX = 1024; +// 64 UI tiles: prompt 0-3, digits 4-23, meter 24-25, brick 26-27, ball 28, +// paddle 29-32, player bullet 33, enemy bullet 34, impact spark 35, shockwave 36-37 +export const OBJ_UI_BASE = 960; +export const PALBANK_OBJ_UI = 15; +export const UIT_PBULLET = 33; +export const UIT_EBULLET = 34; +export const UIT_SPARK = 35; +export const UIT_SHOCK = 36; // 16x8, 2 tiles + +// OAM slot plan: 0..15 scene sprites, 16..21 counter digits, 24 A-prompt, +// 26..41 meter segments (2 meters x 8), 48..119 breakout bricks, 120 ball, +// 121 paddle. +export const MAX_SPRITES = 16; +export const OAM_COUNTER = 16; +export const COUNTER_DIGITS = 6; +export const OAM_PROMPT = 24; +export const OAM_METER = 26; +export const METER_SEGS = 8; // 8 segments x 8px = 64px bar +export const OAM_BRICK = 48; +export const BRICK_COLS = 12; +export const BRICK_ROWS_MAX = 6; +export const OAM_BALL = 120; +export const OAM_PADDLE = 121; + +export const MAX_SCENES = 20; +export const MAX_PROTOS = 12; +export const MAX_TWEENS = 16; +export const N_VARS = 32; // named game vars + per-cue locals share this pool +export const N_FLAGS = 16; +export const MAX_CHOICES = 5; + +// --- world (top-down grid) scenes ---------------------------------------------- +// kind: what a scene IS. CINE keeps all four parallax layers; WORLD repurposes +// BG1 as a 64x64 tilemap (screenblocks 24-27, so no far/sky layers) and gives +// the player a grid walker. Encounters are CINE scenes using meters/choices. +export const SCENE_CINE = 0; +export const SCENE_WORLD = 1; + +export const CELL_PX = 16; // one walk cell = 2x2 hardware tiles +export const WORLD_COLS_MAX = 25; // 400px / 16 (pixflux max canvas) +export const WORLD_ROWS_MAX = 25; +export const MAX_NPCS = 8; +export const MAX_TRIGS = 12; +export const MAX_CUES = 24; // per scene: play + npc/trigger cues + +export const DIR_DOWN = 0; +export const DIR_UP = 1; +export const DIR_LEFT = 2; +export const DIR_RIGHT = 3; + +// trigger kinds +export const TRIG_EXIT = 0; // step onto -> OP_WORLD returns, pushes value +export const TRIG_EXAMINE = 1; // face + A -> run cue, then resume roaming +export const TRIG_AUTO = 2; // step onto -> run cue once (sets its seen-flag) + +// walker proto sheet layout: rows DOWN, UP, SIDE (right = SIDE hflipped), +// walk_fpd frames per row; frame 0 of each row = standing. +export const WALK_ROW_DOWN = 0; +export const WALK_ROW_UP = 1; +export const WALK_ROW_SIDE = 2; +export const STEP_FRAMES = 8; // frames per 16px cell step (2px/frame) + +// --- action (side-scrolling run-and-gun) scenes -------------------------------- +// kind SCENE_ACTION renders like CINE (main pan + far/sky parallax; the 64x32 +// map wraps at 512px so stages may be longer than their art) and adds a +// physics core: player run/jump/shoot, an enemy pool with per-behavior AI, a +// shared bullet pool, wave gates, one-way platforms and the Sandevistan +// (hold R: world runs 1-of-3 frames, player full rate, BG palette swaps to a +// compiler-tinted copy, afterimage ghosts trail). OP_ACTION blocks in +// WAITING_ACTION and pushes ACT_CLEARED — or ACT_BOSS_PHASE when the boss' +// HP crosses its scripted threshold and the story takes over. +export const SCENE_ACTION = 2; + +export const MAX_ENEMIES = 8; // simultaneously alive +export const MAX_BULLETS = 16; // shared pool (player + enemy) +export const MAX_SPAWNS = 24; // per stage +export const MAX_PLATS = 8; +export const MAX_GATES = 6; +export const ACT_GHOSTS = 3; // sandevistan afterimages + +// OAM: action reuses breakout's range (the two modes never co-run) +export const OAM_ENEMY = 48; // 8 slots (a boss occupies its spawn's slot) +export const OAM_BULLET = 56; // 16 slots +export const OAM_GHOST = 72; // 3 afterimages + +// enemy behaviors +export const EB_THUG = 0; // walks at the player, lunges close-in +export const EB_GUNNER = 1; // keeps range, aimed 3-round bursts +export const EB_DRONE = 2; // sine hover, drops aimed shots +export const EB_TURRET = 3; // static hardpoint, steady aimed fire +export const EB_BOSS = 4; // phase machine: spread / telegraphed charge / slam + +// player action-sheet frame convention (8-frame side-view strip) +export const PF_IDLE = 0; +export const PF_RUN0 = 1; // 4 run frames: PF_RUN0..PF_RUN0+3 +export const PF_JUMP = 5; +export const PF_SHOOT = 6; +export const PF_HURT = 7; +export const PLAYER_FRAMES = 8; + +// enemy sheet frame convention (4-frame strip) +export const EF_IDLE = 0; +export const EF_WALK0 = 1; +export const EF_WALK1 = 2; +export const EF_ATTACK = 3; +export const ENEMY_FRAMES = 4; + +// OP_ACTION results +export const ACT_CLEARED = 1; +export const ACT_BOSS_PHASE = 2; + +// stage exit kinds +export const AEXIT_END = 0; // reach stage length +export const AEXIT_CLEAR = 1; // every spawn dead + +// physics tuning (q4 px/frame unless noted) +export const ACT_RUN_VX = 24; // 1.5 px/f +export const ACT_SANDE_VX = 34; // ~2.1 px/f while time is slowed +export const ACT_GRAVITY = 4; // 0.25 px/f^2 +export const ACT_JUMP_VY = -58; // ~3.6 px/f up +export const ACT_BULLET_VX = 64; // player shots, 4 px/f +export const ACT_EBULLET_VX = 28; // enemy shots, 1.75 px/f +export const ACT_SHOOT_CD = 12; // frames between shots +export const ACT_MELEE_RANGE = 20; // px; B this close = melee (2 dmg) +export const ACT_IFRAMES = 60; +export const ACT_SLOW_DIV = 3; // sandevistan: world runs 1 of N frames +export const ACT_SANDE_DRAIN = 4; // frames per gauge unit held +export const ACT_SANDE_REGEN = 12; // frames per gauge unit recovered + +// --- music (DirectSound A PCM streaming) ---------------------------------------- +// s8 mono PCM in ROM, streamed by DMA1 into FIFO_A on Timer 0. 13379 Hz is the +// classic GBA rate: exactly 224 samples/frame, so the VBlank counter is the +// stream clock — no IRQ on FIFO needed. Tracks are u32-padded for 32-bit DMA. +export const MAX_TRACKS = 4; +export const MUSIC_RATE = 13379; +export const MUSIC_TIMER = 65536 - 1254; // 16777216 / 1254 = 13379 Hz +export const MUSIC_SPF = 224; // samples consumed per 59.73 Hz frame + +// --- cue bytecode ------------------------------------------------------------- +// One byte op + little-endian args. Blocking ops suspend the VM until done. +export const OP = { + END: 0x00, // scene done -> next scene in film order (or film end) + WAIT: 0x01, // u16 frames + WAITA: 0x02, // blinking A prompt + WAIT_TWEENS: 0x03, + FADE: 0x04, // u8 mode (FADE_*), u16 frames — blocking BLDY fade + CAPTION: 0x05, // u8 style, u16 text — blocking while typing, stays shown + CAPTION_CLR: 0x06, // u8 style (0xff = all) + DIALOG: 0x07, // u16 speaker_text, u16 body_text — type, waitA, clear + CHOICE: 0x08, // u8 n, u16 ids[n] — pushes result + TWEEN: 0x09, // u8 target, u8 ease, s16 to, u16 frames — non-blocking + SPRITE_SHOW: 0x0a, // u8 slot, u8 proto, s16 x, s16 y, u8 flags + SPRITE_HIDE: 0x0b, // u8 slot + SPRITE_ANIM: 0x0c, // u8 slot, u8 mode (0 static,1 loop), u8 frame_or_fps + SPRITE_MOVE: 0x0d, // u8 slot, u8 ease, s16 x, s16 y, u16 frames — non-blocking + CONTROL: 0x0e, // u8 slot, s16 exit_x, u8 speed_q4 — blocking L/R walk + MASH: 0x0f, // u8 var, u16 target — blocking, A presses increment var + GOTO_SCENE: 0x10, // u8 scene + RASTER: 0x11, // u8 mode (RASTER_*), u8 amp_or_table + SFX: 0x12, // u8 id + COUNTER: 0x13, // u8 var, u8 show, s16 x, s16 y — OBJ digit HUD bound to var + AFFINE: 0x14, // u8 slot, u8 on — sprite uses affine matrix 0 (dbl-size) + LETTERBOX: 0x15, // u8 px, u16 frames — tween letterbox bar height + WORLD: 0x16, // (world scenes) blocking free roam; exit trigger pushes value + BREAKOUT: 0x17, // u8 rows, u8 lives, u16 budget frames — blocking; pushes bricks cleared + METER: 0x18, // u8 id, u8 var, s16 x, s16 y, u8 max, u8 show — HUD bar bound to var + WARP: 0x19, // u8 cx, u8 cy, u8 dir — reposition player on the grid + FACE: 0x1a, // u8 slot, u8 dir — set a walker sprite's facing row + WALK: 0x1b, // u8 slot, u8 cx, u8 cy — blocking scripted grid walk (x then y) + ACTION: 0x1c, // (action scenes) blocking run-and-gun; pushes ACT_* result + MUSIC: 0x1d, // u8 track (0xff stop) — DirectSound PCM insert song + + PUSH: 0x20, // s16 + SET_VAR: 0x21, // u8 (pop) + GET_VAR: 0x22, // u8 (push) + ADD_VAR: 0x23, // u8, s16 (var += imm) + SET_FLAG: 0x24, // u8 + CLR_FLAG: 0x25, // u8 + GET_FLAG: 0x26, // u8 (push) + CMP: 0x27, // u8 kind (pop b, a; push a?b) + JZ: 0x28, // u16 (pop; jump if 0) + JMP: 0x29, // u16 + RND: 0x2a, // u8 n (push 0..n-1) + POP: 0x2b, +} as const; + +export const CMP_EQ = 0, CMP_NE = 1, CMP_LT = 2, CMP_GT = 3, CMP_LE = 4, CMP_GE = 5; + +export const FADE_IN_BLACK = 0; +export const FADE_OUT_BLACK = 1; +export const FADE_IN_WHITE = 2; +export const FADE_OUT_WHITE = 3; + +export const RASTER_OFF = 0; +export const RASTER_GRADIENT = 1; // per-line backdrop color from scene table +export const RASTER_WAVE_MAIN = 2; // per-line BG1 HOFS sine (amp tweenable) +export const RASTER_WAVE_FAR = 3; // per-line BG2 HOFS sine + +// caption styles +export const CAP_CHIP = 0; // top-left place/date chip (1 line) +export const CAP_SUB = 1; // bottom subtitle bar (<=2 lines) +export const CAP_CARD = 2; // centered title card (<=2 lines) +export const CAP_DIALOG = 3; // internal: dialog body (speaker chip + 2 lines) + +// sprite flags (SPRITE_SHOW) +export const SPR_HFLIP = 1; +export const SPR_SCREEN = 2; // screen-space (ignores camera) +export const SPR_BEHIND = 4; // prio 3 (behind main stage) +export const SPR_GHOST = 8; // OBJ semi-transparency (memory figures) + +// tween targets +export const TW = { + CAM_X: 0, + CAM_Y: 1, + BLDY: 2, // 0..16 + EVA: 3, // 0..16 (BG1 1st-target alpha) + EVB: 4, + MOSAIC: 5, // 0..15 + WAVE_AMP: 6, // raster sine amplitude, px + LETTERBOX: 7, // bar height px (0..32), windowed, raster-assisted + SHAKE: 8, // screen shake amplitude px + FAR_VX: 9, // far autoscroll, q8 px/frame + SKY_VX: 10, // sky autoscroll, q8 px/frame + OBJ_SCALE: 11, // affine matrix 0 scale, q8 (256 = 1.0) + OBJ_ANGLE: 12, // affine matrix 0 angle, 0..255 +} as const; +// sprite x/y tweens are encoded as 0x40 | slot<<1 | axis +export const TW_SPRITE_BASE = 0x40; + +export const EASE_LINEAR = 0, EASE_IN = 1, EASE_OUT = 2, EASE_INOUT = 3; + +export const SFX_BLIP = 0, SFX_CONFIRM = 1, SFX_WHOOSH = 2, SFX_STAR = 3; + +// waiting states (debug block `waiting`) +export const WAITING = { + RUN: 0, + A: 1, + DIALOG: 2, + CHOICE: 3, + CONTROL: 4, + MASH: 5, + FILM_DONE: 6, + BUSY: 7, // wait/fade/typewriter/scripted-walk + WORLD: 8, // free roam (OP_WORLD) + MINIGAME: 9, // breakout in flight + ACTION: 10, // run-and-gun stage in flight (OP_ACTION) +} as const; + +// --- text encoding (same scheme as aot cjk16) --------------------------------- +// 0x00 end, 0x0a newline, 0x20..0x7e ASCII (halfcell), 0x80|hi lo fullwidth +// glyph id. Glyph store = 95 baked ASCII halfcells + 2 halfcells per fullwidth +// glyph; each halfcell = two stacked 4bpp 8x8 tiles = 64 bytes, ink=UI_INK on 0. +export const TOK_END = 0x00; +export const TOK_NL = 0x0a; +export const ASCII_HALF = 95; // halfcells 0..94 = codepoints 0x20..0x7e +export const CAP_COLS = 26; // max text cells per caption line +export const CAP_LINES = 2; + +// --- debug block (EWRAM 0x02000000) -------------------------------------------- +export const DEBUG_ADDR = 0x02000000; +export const DBG_MAGIC = 0x45474445; // "EDGE" (LE bytes E,D,G,E) +export const DBG = { + MAGIC: 0x00, // u32 + BOOTED: 0x04, // u8 + SCENE: 0x05, // u8 + WAITING: 0x06, // u8 (WAITING.*) + LAST_CHOICE: 0x07, // s8 (-1 none) + FRAME: 0x08, // u16 scene-local frame + CUE_IP: 0x0a, // u16 + CAM_X: 0x0c, // s16 + CUR_TEXT: 0x0e, // u16 (last caption/dialog text id + 1; 0 = none) + TWEEN_MASK: 0x10, // u16 + CAPTION_BUSY: 0x12, // u8 + FILM_DONE: 0x13, // u8 + VARS: 0x14, // s16[N_VARS] + SPR0_X: 0x54, // s16 (sprite slot 0 world x — control assertions) + SPR0_Y: 0x56, // s16 + PLAYER_CX: 0x58, // u8 world-grid cell + PLAYER_CY: 0x59, // u8 + PLAYER_DIR: 0x5a, // u8 DIR_* + BRICKS: 0x5b, // u8 breakout bricks remaining + KIND: 0x5c, // u8 SCENE_* of current scene + ACT_X: 0x5e, // s16 action player world x + ACT_ENEMIES: 0x60, // u8 enemies alive + ACT_BOSS_HP: 0x61, // u8 boss hp (0 when no boss) + ACT_SANDE: 0x62, // u8 sandevistan engaged + MUSIC: 0x63, // u8 playing track + 1 (0 = silent) +} as const; + +// --- ByteWriter ---------------------------------------------------------------- +export class ByteWriter { + private buf: number[] = []; + get length(): number { + return this.buf.length; + } + u8(v: number): this { + this.buf.push(v & 0xff); + return this; + } + u16(v: number): this { + this.buf.push(v & 0xff, (v >> 8) & 0xff); + return this; + } + i16(v: number): this { + return this.u16(v & 0xffff); + } + u32(v: number): this { + this.buf.push(v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff); + return this; + } + bytes(b: ArrayLike): this { + for (let i = 0; i < b.length; i++) this.buf.push(b[i] & 0xff); + return this; + } + patchU16(at: number, v: number): this { + this.buf[at] = v & 0xff; + this.buf[at + 1] = (v >> 8) & 0xff; + return this; + } + toUint8Array(): Uint8Array { + return Uint8Array.from(this.buf); + } +} + +export function rgb555(r: number, g: number, b: number): number { + return ((r >> 3) & 31) | (((g >> 3) & 31) << 5) | (((b >> 3) & 31) << 10); +} + +export function hex555(hex: string): number { + const h = hex.replace("#", ""); + return rgb555(parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)); +} diff --git a/edge/spec/gen-c.ts b/edge/spec/gen-c.ts new file mode 100644 index 0000000..ab79bab --- /dev/null +++ b/edge/spec/gen-c.ts @@ -0,0 +1,26 @@ +// edge/spec/gen-c.ts — mirror spec/edge.ts into runtime/edge_gen.h. +// bun edge/spec/gen-c.ts +import * as S from "./edge.ts"; + +const lines: string[] = [ + "/* edge_gen.h — GENERATED from edge/spec/edge.ts. Do not edit. */", + "#ifndef EDGE_GEN_H", + "#define EDGE_GEN_H", +]; + +const def = (name: string, v: number): void => { + lines.push(`#define ${name} ${v}`); +}; + +for (const [k, v] of Object.entries(S)) { + if (typeof v === "number") def(`C_${k}`, v); +} +for (const [k, v] of Object.entries(S.OP)) def(`OP_${k}`, v); +for (const [k, v] of Object.entries(S.TW)) def(`TW_${k}`, v); +for (const [k, v] of Object.entries(S.WAITING)) def(`WAITING_${k}`, v); +def("DBG_MAGIC_VAL", S.DBG_MAGIC); +for (const [k, v] of Object.entries(S.DBG)) def(`DBGO_${k}`, v); + +lines.push("#endif", ""); +await Bun.write(new URL("../runtime/edge_gen.h", import.meta.url).pathname, lines.join("\n")); +console.log("wrote runtime/edge_gen.h"); diff --git a/edge/test/crop.ts b/edge/test/crop.ts new file mode 100644 index 0000000..f71e647 --- /dev/null +++ b/edge/test/crop.ts @@ -0,0 +1,22 @@ +// edge/test/crop.ts — crop + integer-scale a PNG region for close inspection. +// bun test/crop.ts [scale] +import { decodePng, encodePng } from "../compiler/png.ts"; + +const [path, xs, ys, ws, hs, ss] = process.argv.slice(2); +const img = decodePng(new Uint8Array(await Bun.file(path).arrayBuffer())); +const x0 = +xs, y0 = +ys, w = +ws, h = +hs, s = +(ss ?? 4); +const out = new Uint8Array(w * s * h * s * 4); +for (let y = 0; y < h * s; y++) + for (let x = 0; x < w * s; x++) { + const sx = x0 + Math.floor(x / s); + const sy = y0 + Math.floor(y / s); + const si = (sy * img.width + sx) * 4; + const di = (y * w * s + x) * 4; + out[di] = img.rgba[si]; + out[di + 1] = img.rgba[si + 1]; + out[di + 2] = img.rgba[si + 2]; + out[di + 3] = 255; + } +const dst = path.replace(/\.png$/, `.crop.png`); +await Bun.write(dst, encodePng(out, w * s, h * s)); +console.log(dst); diff --git a/edge/test/e2e.ts b/edge/test/e2e.ts new file mode 100644 index 0000000..d50612b --- /dev/null +++ b/edge/test/e2e.ts @@ -0,0 +1,347 @@ +// edge/test/e2e.ts — full playthrough of SEE YOU ON THE MOON, headless. +// +// bun test/e2e.ts +// +// One continuous mgba run drives all 12 scenes: title → apartment → clinic → +// ALLEY → train → hideout → WAREHOUSE → rooftop(song) → STREET → LAB → +// TOWER(boss) → moon(credits) → FILM_DONE, asserting the debug block at every +// stage boundary: scene ids, scene kinds, music stream state, the boss phase +// result, kills/deaths and the final film_done flag. +// +// World-scene navigation exploits the grid stepper's determinism: holds are +// either wall-terminated (overshoot-safe) or exact multiples of the 8-frame +// cell step. Action stages use a resilient advance-and-strafe pattern; death +// respawns at the last gate, so progress is monotonic. + +import { $ } from "bun"; +import { compileFilm } from "../compiler/index.ts"; +import { emitGenData, emitGenMusic } from "../compiler/emit.ts"; +import { buildRom } from "../compiler/rom.ts"; +import { + DEBUG_ADDR, DBG, DBG_MAGIC, WAITING, + SCENE_CINE, SCENE_WORLD, SCENE_ACTION, ACT_BOSS_PHASE, +} from "../spec/edge.ts"; + +const HERE = new URL(".", import.meta.url).pathname; +const ROOT = HERE + "../"; +const RUNNER = ROOT + "../aot/test/harness/mgba_runner"; +const ROM = ROOT + "dist/see-you-on-the-moon.gba"; +const SHOTS = ROOT + "dist/shots"; + +type Step = + | { op: "advance"; frames: number } + | { op: "press"; buttons: string[]; frames: number; release?: number } + | { op: "read"; name: string; addr: number; size: 1 | 2 | 4 } + | { op: "screenshot"; path: string }; + +const A = (n = 8): Step => ({ op: "press", buttons: ["A"], frames: 2, release: n }); +const hold = (b: string, frames: number, release = 4): Step => ({ op: "press", buttons: [b], frames, release }); +/** exactly one grid cell step (4 press + 8 release = one 8-frame step, no 2nd) */ +const step1 = (b: string): Step => ({ op: "press", buttons: [b], frames: 4, release: 8 }); +const adv = (frames: number): Step => ({ op: "advance", frames }); +const rd = (name: string, off: number, size: 1 | 2 | 4 = 1): Step => ({ op: "read", name, addr: DEBUG_ADDR + off, size }); +const shot = (n: string): Step => ({ op: "screenshot", path: `${SHOTS}/${n}.ppm` }); + +/** one dialog beat: finish typing (or fast-forward) then dismiss */ +const dlg = (n = 1): Step[] => + Array.from({ length: n }, (): Step[] => [adv(110), A(), adv(8), A(12)]).flat(); +/** one blocking-caption + waitA beat */ +const wA = (): Step[] => [adv(90), A(12)]; +/** run-and-gun advance: run right, snap shot, anti-air shot */ +const fight = (rounds: number): Step[] => + Array.from({ length: rounds }, (): Step[] => [ + hold("RIGHT", 50), + hold("B", 4, 6), + adv(4), + { op: "press", buttons: ["B", "UP"], frames: 4, release: 8 }, + ]).flat(); +/** boss loop: hold ground, shoot, back off, shoot up */ +const bossRounds = (rounds: number): Step[] => + Array.from({ length: rounds }, (): Step[] => [ + hold("B", 4, 8), + { op: "press", buttons: ["B", "UP"], frames: 4, release: 8 }, + hold("LEFT", 14), + hold("B", 4, 8), + adv(6), + ]).flat(); + +async function run(steps: Step[]): Promise> { + const scenario = ROOT + "dist/e2e-scenario.json"; + await Bun.write(scenario, JSON.stringify({ steps })); + const out = await $`${RUNNER} ${ROM} ${scenario}`.text(); + const line = out.trim().split("\n").reverse().find((l) => l.trim().startsWith("{")); + if (!line) throw new Error("runner produced no JSON:\n" + out); + const parsed = JSON.parse(line); + if (!parsed.ok) throw new Error("runner error: " + JSON.stringify(parsed)); + return parsed.reads ?? {}; +} + +let passed = 0; +let failed = 0; +function check(name: string, got: unknown, want: unknown): void { + const ok = got === want; + console.log(` ${ok ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}: got ${got}${ok ? "" : `, want ${want}`}`); + ok ? passed++ : failed++; +} +function checkTrue(name: string, got: boolean, detail = ""): void { + console.log(` ${got ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}${detail ? `: ${detail}` : ""}`); + got ? passed++ : failed++; +} + +async function main(): Promise { + console.log("Building SEE YOU ON THE MOON..."); + const film = await compileFilm(ROOT + "game/see-you-on-the-moon.ts"); + const rom = await buildRom(emitGenData(film), ROM, "MOONSMILE", emitGenMusic(film)); + await $`mkdir -p ${SHOTS}`.quiet(); + console.log(`ROM: ${rom.size} bytes\n`); + + const sc = film.debug.sceneIds; + const varAddr = (name: string): number => { + const idx = film.debug.vars[name]; + if (idx === undefined) throw new Error("unknown var " + name); + return DBG.VARS + idx * 2; + }; + + const steps: Step[] = [ + // ---- S0 title -------------------------------------------------------------- + adv(60), + rd("magic", DBG.MAGIC, 4), + rd("kind_title", DBG.KIND), + adv(260), // fadeIn + card + chip + sub typing + shot("s0_title"), + A(10), + adv(80), // fadeOut + load + rd("scene_apartment", DBG.SCENE), + + // ---- S1 apartment ---------------------------------------------------------- + adv(90), // fadeIn + chip + shot("s1_apartment"), + ...dlg(5), + adv(240), // fadeOut, crash card (wait 80), fadeIn + ...wA(), // trauma-team sub + adv(90), // walkTo + dialog + ...dlg(1), + ...wA(), // clinic sub + adv(90), // fadeOut + load + rd("scene_clinic", DBG.SCENE), + + // ---- S2 clinic -------------------------------------------------------------- + adv(80), + ...wA(), // relic sub + ...dlg(4), + shot("s2_clinic"), + adv(260), // mosaic + card(70) + fadeOut + rd("scene_alley", DBG.SCENE), + + // ---- S3 ALLEY (tutorial action) --------------------------------------------- + adv(60), + ...dlg(2), + adv(90), // tutorial sub types, meters, OP_ACTION + rd("kind_alley", DBG.KIND), + rd("alley_waiting", DBG.WAITING), + shot("s3_alley"), + hold("R", 30, 0), // engage the sandevistan right away (stage guaranteed live) + rd("alley_sande", DBG.ACT_SANDE), + shot("s3_alley_sande"), + hold("RIGHT", 90), // to gate 1 (also releases R) + ...fight(4), // thug 1 + ...fight(14), // gate 2 pair + push to clear + ...fight(8), + adv(80), + rd("alley_kills", varAddr("kills"), 2), + ...wA(), // post-stage caption + adv(80), + rd("scene_train", DBG.SCENE), + + // ---- S4 train (world) -------------------------------------------------------- + adv(90), + rd("kind_train", DBG.KIND), + shot("s4_train"), + hold("RIGHT", 16), // (1,5) -> (2,5) + hold("UP", 16), // -> (2,4) + hold("RIGHT", 220), // wall-terminated: blocked by Lucy at (16,4) -> stand (15,4) + A(10), // talk + adv(130), // slow-time caption types + A(12), + ...dlg(4), + ...wA(), // "去车厢尽头的门" + step1("DOWN"), // exactly one step -> row 5 + hold("RIGHT", 140), // onto the door (18,5) -> exit + adv(90), + rd("scene_hideout", DBG.SCENE), + + // ---- S5 hideout (world) -------------------------------------------------------- + // start (12,13); the grid is a maze, only col 1 is a clean corridor. L-path + // to Maine (3,4) via the left wall + top row, then back down col 1 and along + // row 13 to the door (9,14). Every move is wall-terminated or an exact step. + adv(90), + shot("s5_hideout"), + hold("LEFT", 200), // -> (1,13) + hold("UP", 200), // -> (1,3) + step1("RIGHT"), step1("RIGHT"), // -> (3,3), above Maine + hold("DOWN", 8), // face Maine (solid) -> stays (3,3) + A(10), + ...dlg(3), // Maine x2 + Maine "why" + adv(100), // choice renders + A(12), // pick option 0 + ...dlg(3), // branch reply + trial-job x2 + ...wA(), // "走出后门" + hold("LEFT", 40), // back to (1,3) + hold("DOWN", 200), // col 1 -> (1,13) + step1("RIGHT"), step1("RIGHT"), step1("RIGHT"), step1("RIGHT"), + step1("RIGHT"), step1("RIGHT"), step1("RIGHT"), step1("RIGHT"), // -> (9,13) + step1("DOWN"), // onto the door (9,14) -> exit + adv(90), + rd("scene_warehouse", DBG.SCENE), + + // ---- S6 WAREHOUSE (action) ----------------------------------------------------- + adv(70), + ...dlg(1), // rebecca + adv(50), + rd("kind_wh", DBG.KIND), + shot("s6_warehouse"), + ...fight(20), + ...fight(12), + adv(80), + rd("wh_kills", varAddr("kills"), 2), + ...dlg(2), // rebecca post + david + adv(90), + rd("scene_rooftop", DBG.SCENE), + + // ---- S7 rooftop (the song) ------------------------------------------------------ + adv(110), // music starts on the first op + rd("music_rooftop", DBG.MUSIC), + rd("kind_rooftop", DBG.KIND), + adv(130), // wait 90 + chip + shot("s7_rooftop"), + ...dlg(5), + adv(60), // wait 40 + ...dlg(2), + adv(100), + A(12), // choice: 我保证 + ...dlg(2), + adv(240), // song title card + wait(180) + shot("s7_rooftop_song"), + adv(280), // wait(120) + fadeOut(80) + music off + rd("scene_street", DBG.SCENE), + rd("music_after_rooftop", DBG.MUSIC), + + // ---- S8 STREET (action + Maine) --------------------------------------------------- + adv(70), + ...wA(), // trap sub + adv(40), + shot("s8_street"), + ...fight(22), + ...fight(12), + adv(80), + ...dlg(5), // Maine's last stand + adv(280), // white flash + card(100) + fades + rd("scene_lab", DBG.SCENE), + rd("street_deaths", varAddr("deaths"), 2), + + // ---- S9 LAB (cyberskeleton action) ------------------------------------------------ + adv(80), + ...wA(), + ...wA(), + ...dlg(2), // fake lucy + david + adv(130), // mosaic shake + sync caption + ...wA(), + adv(40), + shot("s9_lab"), + ...fight(22), + ...fight(12), + adv(80), + ...wA(), // glitch caption + adv(90), + rd("scene_tower", DBG.SCENE), + rd("lab_kills", varAddr("kills"), 2), + + // ---- S10 TOWER (boss) --------------------------------------------------------------- + adv(80), + ...dlg(3), // rebecca x2 + david + adv(50), + rd("kind_tower", DBG.KIND), + shot("s10_tower"), + ...fight(8), // the two cops at the gate + hold("RIGHT", 60), + adv(40), + rd("tower_boss_hp", DBG.ACT_BOSS_HP), + shot("s10_boss"), + ...bossRounds(18), + ...fight(4), // walk back in if we got knocked away + ...bossRounds(20), + adv(100), + ...dlg(1), // SMASHER + ...wA(), // burnout caption + ...wA(), // rebecca caption + adv(130), // lucy walks in + shot("s10_farewell"), + ...dlg(6), // farewell + adv(240), // white fadeOut + rd("scene_moon", DBG.SCENE), + rd("endfight", varAddr("endfight"), 2), + + // ---- S11 moon --------------------------------------------------------------------- + adv(130), + rd("music_moon", DBG.MUSIC), + shot("s11_moon"), + adv(240), // lucy walks + sub + ...wA(), + ...dlg(1), + ...wA(), + adv(900), // credits cards + shot("s11_credits"), + adv(700), + adv(400), + rd("film_done", DBG.FILM_DONE), + rd("final_waiting", DBG.WAITING), + rd("final_kills", varAddr("kills"), 2), + rd("final_deaths", varAddr("deaths"), 2), + ]; + + const r = await run(steps); + + console.log("Scene flow"); + check("debug magic 'EDGE'", r.magic >>> 0, DBG_MAGIC); + check("title is CINE", r.kind_title, SCENE_CINE); + check("title -> apartment", r.scene_apartment, sc.apartment); + check("apartment -> clinic", r.scene_clinic, sc.clinic); + check("clinic -> alley", r.scene_alley, sc.alley); + check("alley is ACTION", r.kind_alley, SCENE_ACTION); + check("alley stage running", r.alley_waiting, WAITING.ACTION); + check("sandevistan engages", r.alley_sande, 1); + checkTrue("alley kills counted", r.alley_kills >= 3, `kills=${r.alley_kills}`); + check("alley -> train", r.scene_train, sc.train); + check("train is WORLD", r.kind_train, SCENE_WORLD); + check("train -> hideout", r.scene_hideout, sc.hideout); + check("hideout -> warehouse", r.scene_warehouse, sc.warehouse); + check("warehouse is ACTION", r.kind_wh, SCENE_ACTION); + checkTrue("warehouse kills grew", r.wh_kills >= 8, `kills=${r.wh_kills}`); + check("warehouse -> rooftop", r.scene_rooftop, sc.rooftop); + console.log("The song"); + check("rooftop is CINE", r.kind_rooftop, SCENE_CINE); + check("insert song streaming (track 1)", r.music_rooftop, 1); + check("song stopped after the scene", r.music_after_rooftop, 0); + check("rooftop -> street", r.scene_street, sc.street); + console.log("The fall"); + check("street -> lab", r.scene_lab, sc.lab); + checkTrue("deaths within mercy rules", r.street_deaths >= 0 && r.street_deaths <= 12, `deaths=${r.street_deaths}`); + check("lab -> tower", r.scene_tower, sc.tower); + checkTrue("lab kills grew", r.lab_kills >= 15, `kills=${r.lab_kills}`); + console.log("The boss"); + check("tower is ACTION", r.kind_tower, SCENE_ACTION); + checkTrue("smasher took the field", r.tower_boss_hp > 0 && r.tower_boss_hp <= 40, `boss_hp=${r.tower_boss_hp}`); + check("boss fight ended scripted", r.endfight, ACT_BOSS_PHASE); + check("tower -> moon", r.scene_moon, sc.moon); + console.log("The moon"); + check("reprise streaming (track 2)", r.music_moon, 2); + check("film completes", r.film_done, 1); + check("final waiting is FILM_DONE", r.final_waiting, WAITING.FILM_DONE); + checkTrue("final kills sane", r.final_kills >= 18, `kills=${r.final_kills}`); + checkTrue("final deaths sane", r.final_deaths <= 20, `deaths=${r.final_deaths}`); + + console.log(`\n${failed === 0 ? "\x1b[32m" : "\x1b[31m"}${passed} passed, ${failed} failed\x1b[0m`); + process.exit(failed === 0 ? 0 : 1); +} + +await main(); diff --git a/edge/test/engine-e2e.ts b/edge/test/engine-e2e.ts new file mode 100644 index 0000000..9d641e7 --- /dev/null +++ b/edge/test/engine-e2e.ts @@ -0,0 +1,237 @@ +// edge/test/engine-e2e.ts — headless engine test for the NEW edge paths: +// world scenes (grid walk, collision, NPC talk, examine spot, exit trigger) +// and the breakout minigame + meters. Uses the smoke film (placeholder art). +// +// bun test/engine-e2e.ts + +import { $ } from "bun"; +import { compileFilm } from "../compiler/index.ts"; +import { emitGenData, emitGenMusic } from "../compiler/emit.ts"; +import { buildRom } from "../compiler/rom.ts"; +import { + DEBUG_ADDR, DBG, DBG_MAGIC, WAITING, SCENE_WORLD, SCENE_ACTION, ACT_CLEARED, DIR_UP, DIR_DOWN, +} from "../spec/edge.ts"; + +const HERE = new URL(".", import.meta.url).pathname; +const ROOT = HERE + "../"; +const RUNNER = ROOT + "../aot/test/harness/mgba_runner"; +const ROM = ROOT + "dist/smoke.gba"; +const SHOTS = ROOT + "dist/shots"; + +type Step = + | { op: "advance"; frames: number } + | { op: "press"; buttons: string[]; frames: number; release?: number } + | { op: "read"; name: string; addr: number; size: 1 | 2 | 4 } + | { op: "screenshot"; path: string }; + +const A = (n = 8): Step => ({ op: "press", buttons: ["A"], frames: 1, release: n }); +const hold = (b: string, frames: number): Step => ({ op: "press", buttons: [b], frames, release: 4 }); +const adv = (frames: number): Step => ({ op: "advance", frames }); +const rd = (name: string, off: number, size: 1 | 2 | 4 = 1): Step => ({ op: "read", name, addr: DEBUG_ADDR + off, size }); +const shot = (n: string): Step => ({ op: "screenshot", path: `${SHOTS}/${n}.ppm` }); + +async function run(steps: Step[]): Promise> { + const scenario = ROOT + "dist/engine-e2e-scenario.json"; + await Bun.write(scenario, JSON.stringify({ steps })); + const out = await $`${RUNNER} ${ROM} ${scenario}`.text(); + const line = out.trim().split("\n").reverse().find((l) => l.trim().startsWith("{")); + if (!line) throw new Error("runner produced no JSON:\n" + out); + const parsed = JSON.parse(line); + if (!parsed.ok) throw new Error("runner error: " + JSON.stringify(parsed)); + return parsed.reads ?? {}; +} + +let passed = 0; +let failed = 0; +function check(name: string, got: unknown, want: unknown): void { + const ok = got === want; + console.log(` ${ok ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}: got ${got}${ok ? "" : `, want ${want}`}`); + ok ? passed++ : failed++; +} +function checkTrue(name: string, got: boolean, detail = ""): void { + console.log(` ${got ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}${detail ? `: ${detail}` : ""}`); + got ? passed++ : failed++; +} + +async function main(): Promise { + console.log("Building smoke film..."); + const film = await compileFilm(ROOT + "test/smoke-film.ts"); + const rom = await buildRom(emitGenData(film), ROM, "EDGESMOKE", emitGenMusic(film)); + await $`mkdir -p ${SHOTS}`.quiet(); + console.log(`ROM: ${rom.size} bytes\n`); + + const sc = film.debug.sceneIds; + const varAddr = (name: string): number => { + const idx = film.debug.vars[name]; + if (idx === undefined) throw new Error("unknown var " + name); + return DBG.VARS + idx * 2; + }; + + console.log("Scenario 1 — world scene: roam, collision, NPC, spot, exit"); + { + const r = await run([ + adv(90), // fade in + chip caption + OP_WORLD + rd("magic", DBG.MAGIC, 4), + rd("kind", DBG.KIND), + rd("waiting_world", DBG.WAITING), + rd("cx0", DBG.PLAYER_CX), + rd("cy0", DBG.PLAYER_CY), + shot("engine_world"), + // walk up 4 cells; the NPC on (10,2) blocks the 5th so we stop at (10,3) + hold("UP", 70), + rd("cy_up", DBG.PLAYER_CY), + rd("dir_up", DBG.PLAYER_DIR), + // talk to the NPC (facing up) + A(), + adv(90), // dialog typing + rd("waiting_dialog", DBG.WAITING), + shot("engine_npc"), + A(), // dismiss + adv(12), + rd("talked", varAddr("talked"), 2), + rd("back_to_world", DBG.WAITING), + // to the bench row, then walk left until the solid bench stops us at (5,4) + hold("DOWN", 8), // exactly one step -> (10,4) + hold("LEFT", 70), // 5 steps then blocked by the bench + rd("cx_left", DBG.PLAYER_CX), + rd("cy_row", DBG.PLAYER_CY), + rd("dir_left", DBG.PLAYER_DIR), + A(), // examine the bench (facing (4,4)) + adv(70), + rd("waiting_spot", DBG.WAITING), + A(), + adv(12), + rd("benched", varAddr("benched"), 2), + // to the door: DOWN to the bottom wall (5,13), then RIGHT across the exit + hold("DOWN", 200), + hold("RIGHT", 100), // steps onto (10,13) mid-hold -> exit fires + adv(40), // captionClear + setVar + fadeOut + rd("exit_code", varAddr("exit_code"), 2), + adv(40), + rd("scene_next", DBG.SCENE), + ]); + check("debug magic 'EDGE'", r.magic >>> 0, DBG_MAGIC); + check("scene kind is WORLD", r.kind, SCENE_WORLD); + check("roaming (WAITING_WORLD)", r.waiting_world, WAITING.WORLD); + check("player starts at cx=10", r.cx0, 10); + check("player starts at cy=7", r.cy0, 7); + check("NPC blocks at cy=3", r.cy_up, 3); + check("facing up", r.dir_up, DIR_UP); + check("NPC talk opens dialog", r.waiting_dialog, WAITING.DIALOG); + check("talk cue ran (talked=1)", r.talked, 1); + check("dialog returns to roam", r.back_to_world, WAITING.WORLD); + check("bench blocks at cx=5", r.cx_left, 5); + check("on the bench row (cy=4)", r.cy_row, 4); + check("facing left", r.dir_left, 2); + check("bench spot waits for A", r.waiting_spot, WAITING.A); + check("spot cue ran (benched=1)", r.benched, 1); + check("door exit pushes its value", r.exit_code, 7); + check("exit chains into arcade", r.scene_next, sc.arcade); + } + + console.log("Scenario 3 — action: stage, gate, kills, sandevistan, music, exit"); + { + const preRoll: Step[] = [ + adv(90), // room boots + hold("DOWN", 130), // straight out the door + adv(60), // arcade fade + caption + OP_BREAKOUT + adv(440), // let the breakout budget (420) expire untouched + A(), // dismiss the arcade dialog + adv(80), // fadeOut + scene switch + alley fadeIn + music op + ]; + const r = await run([ + ...preRoll, + rd("scene_alley", DBG.SCENE), + rd("kind", DBG.KIND), + rd("waiting_action", DBG.WAITING), + rd("music_on", DBG.MUSIC), + rd("hp0", varAddr("hp"), 2), + rd("x0", DBG.ACT_X, 2), + shot("engine_action0"), + // run right toward the gate; the thug (wave 1) activates and closes in + hold("RIGHT", 120), + rd("x_gate", DBG.ACT_X, 2), + rd("enemies1", DBG.ACT_ENEMIES), + // sandevistan: hold R and check the flag + the world slowdown survives + { op: "press", buttons: ["R"], frames: 30, release: 0 }, + rd("sande_on", DBG.ACT_SANDE), + rd("sande_left", varAddr("sande"), 2), + shot("engine_action_sande"), + adv(10), + // kill the thug: face it and fire until it drops (melee if adjacent) + hold("B", 4), adv(20), hold("B", 4), adv(20), hold("B", 4), adv(20), + hold("B", 4), adv(20), hold("B", 4), adv(30), + rd("kills1", varAddr("kills"), 2), + // push on run-and-gun style: run bursts with covering fire, over and over. + // Deaths respawn at the gate checkpoint, so progress is monotonic-ish. + ...Array.from({ length: 16 }, (): Step[] => [ + hold("RIGHT", 55), + hold("B", 4), + adv(6), + { op: "press", buttons: ["B", "UP"], frames: 4, release: 6 }, // anti-drone diagonal + ]).flat(), + rd("x_late", DBG.ACT_X, 2), + ...Array.from({ length: 6 }, (): Step[] => [hold("RIGHT", 55), hold("B", 4), adv(6)]).flat(), + adv(40), // fadeOut after action returns + rd("act_result", varAddr("act_result"), 2), + rd("music_off", DBG.MUSIC), + rd("deaths", varAddr("deaths"), 2), + adv(60), + rd("scene_after", DBG.SCENE), + ]); + check("arcade jumps into alley", r.scene_alley, sc.alley); + check("scene kind is ACTION", r.kind, SCENE_ACTION); + check("stage running (WAITING_ACTION)", r.waiting_action, WAITING.ACTION); + check("music track 0 streaming", r.music_on, 1); + check("hp meter primed", r.hp0, 6); + checkTrue("player starts near the left edge", r.x0 < 60, `x0=${r.x0}`); + checkTrue("gate holds the player at 320", r.x_gate <= 322, `x_gate=${r.x_gate}`); + checkTrue("wave-1 thug activated", r.enemies1 >= 1, `enemies=${r.enemies1}`); + check("sandevistan engages on R", r.sande_on, 1); + checkTrue("sandevistan gauge drains", r.sande_left < 24, `sande=${r.sande_left}`); + checkTrue("thug went down (kills>=1)", r.kills1 >= 1, `kills=${r.kills1}`); + checkTrue("gate opened after the kill", r.x_late > 400, `x_late=${r.x_late}`); + check("stage cleared (ACT_CLEARED)", r.act_result, ACT_CLEARED); + check("music('off') silences the stream", r.music_off, 0); + checkTrue("deaths counted sanely", r.deaths >= 0 && r.deaths <= 3, `deaths=${r.deaths}`); + check("alley falls through to street", r.scene_after, sc.street); + } + + console.log("Scenario 2 — breakout: launch, bricks fall, budget end, meter"); + { + const r = await run([ + adv(90), // room fadein+caption grace (scene 0 boots first) + // replay room quickly: straight to the door + hold("DOWN", 130), + adv(60), + rd("scene_arcade", DBG.SCENE), + adv(60), // arcade fade + caption + meter + OP_BREAKOUT + rd("waiting_minigame", DBG.WAITING), + rd("bricks0", DBG.BRICKS), + shot("engine_breakout0"), + A(), // launch + adv(240), + rd("bricks_mid", DBG.BRICKS), + shot("engine_breakout1"), + adv(260), // budget (420) expires + rd("after_game", DBG.WAITING), + rd("cleared", varAddr("cleared"), 2), + adv(40), + A(), // dismiss dialog + adv(60), + rd("scene_after", DBG.SCENE), + ]); + check("door column exits the room", r.scene_arcade, sc.arcade); + check("breakout running (MINIGAME)", r.waiting_minigame, WAITING.MINIGAME); + check("3 rows x 12 bricks", r.bricks0, 36); + checkTrue("ball cleared some bricks", r.bricks_mid < 36, `bricks_mid=${r.bricks_mid}`); + checkTrue("budget ended the game", r.after_game !== WAITING.MINIGAME, `waiting=${r.after_game}`); + checkTrue("cleared count recorded", r.cleared >= 1 && r.cleared <= 36, `cleared=${r.cleared}`); + check("arcade chains into alley", r.scene_after, sc.alley); + } + + console.log(`\n${failed === 0 ? "\x1b[32m" : "\x1b[31m"}${passed} passed, ${failed} failed\x1b[0m`); + process.exit(failed === 0 ? 0 : 1); +} + +await main(); diff --git a/edge/test/gen-placeholder-art.ts b/edge/test/gen-placeholder-art.ts new file mode 100644 index 0000000..79271d8 --- /dev/null +++ b/edge/test/gen-placeholder-art.ts @@ -0,0 +1,233 @@ +// edge/test/gen-placeholder-art.ts — procedural PNGs for the smoke film so the +// pipeline is testable without PixelLab. + +import { encodePng } from "../compiler/png.ts"; + +const DIR = new URL("./art/", import.meta.url).pathname; + +function img(w: number, h: number, fn: (x: number, y: number) => [number, number, number, number]): Uint8Array { + const rgba = new Uint8Array(w * h * 4); + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const [r, g, b, a] = fn(x, y); + const i = (y * w + x) * 4; + rgba[i] = r; + rgba[i + 1] = g; + rgba[i + 2] = b; + rgba[i + 3] = a; + } + return encodePng(rgba, w, h); +} + +// main stage: 384x160 "street": ground + building blocks + lamp posts +await Bun.write( + DIR + "street.png", + img(384, 160, (x, y) => { + if (y > 128) return [70, 60, 56, 255]; // ground + if (y > 124) return [110, 100, 90, 255]; // curb + const block = Math.floor(x / 48); + const inBuilding = y > 40 + (block % 3) * 16 && x % 48 < 40; + if (inBuilding) { + const win = x % 8 < 4 && y % 12 < 6 && y > 56; + return win ? [230, 200, 120, 255] : [40 + (block % 4) * 12, 44, 60 + (block % 3) * 10, 255]; + } + return [0, 0, 0, 0]; // transparent -> sky shows + }), +); + +// far layer: rolling hill silhouettes (transparent above) +await Bun.write( + DIR + "hills.png", + img(240, 160, (x, y) => { + const ridge = 90 + Math.round(18 * Math.sin(x / 25) + 8 * Math.sin(x / 7)); + return y > ridge ? [24, 34, 52, 255] : [0, 0, 0, 0]; + }), +); + +// walker sprite: 32x32, 2 frames side by side +await Bun.write( + DIR + "walker.png", + img(64, 32, (x, y) => { + const f = x >= 32 ? 1 : 0; + const lx = x % 32; + // head + if (Math.hypot(lx - 16, y - 8) < 5) return [240, 200, 160, 255]; + // body + if (y >= 13 && y < 24 && lx >= 12 && lx < 20) return [66, 184, 131, 255]; + // legs alternate by frame + if (y >= 24 && y < 30) { + const spread = f ? 3 : 1; + if (Math.abs(lx - (16 - spread)) < 2 || Math.abs(lx - (16 + spread)) < 2) return [40, 48, 60, 255]; + } + return [0, 0, 0, 0]; + }), +); + +// emblem: 32x32 V mark +await Bun.write( + DIR + "emblem.png", + img(32, 32, (x, y) => { + const d1 = Math.abs(x - 6 - y * 0.4); + const d2 = Math.abs(x - 26 + y * 0.4); + if (y < 26 && (d1 < 2.5 || d2 < 2.5)) return [66, 184, 131, 255]; + if (y < 26 && (d1 < 4 || d2 < 4)) return [53, 73, 94, 255]; + return [0, 0, 0, 0]; + }), +); + +// world room: 320x240 (20x15 cells), walls on the border + a 2-cell bench +const ROOM_GRID = [ + "####################", + "#..................#", + "#.........w........#", + "#..................#", + "#..##..............#", + "#..................#", + "#..................#", + "#.........p........#", + "#..................#", + "#..................#", + "#..................#", + "#..................#", + "#..................#", + "#.........d........#", + "####################", +]; +await Bun.write( + DIR + "room.png", + img(320, 240, (x, y) => { + const cx = Math.floor(x / 16); + const cy = Math.floor(y / 16); + const ch = ROOM_GRID[cy]?.[cx] ?? "#"; + if (ch === "#") { + const brick = (cx + cy) % 2 === 0; + return brick ? [86, 60, 44, 255] : [70, 48, 36, 255]; + } + if (ch === "d") return [140, 110, 60, 255]; // doormat + const check = (cx + cy) % 2 === 0; + const edge = x % 16 === 0 || y % 16 === 0; + if (edge) return [52, 56, 68, 255]; + return check ? [66, 72, 86, 255] : [60, 66, 80, 255]; + }), +); + +// walker sheets: 16x32 x 6 frames (down x2, up x2, side x2) +function walkerSheet(body: [number, number, number]): Uint8Array { + return img(96, 32, (x, y) => { + const f = Math.floor(x / 16); // 0..5 + const row = Math.floor(f / 2); // 0 down, 1 up, 2 side + const ph = f % 2; + const lx = x % 16; + // head + if (Math.hypot(lx - 8, y - 9) < 5) { + // face features by direction + if (row === 0 && y >= 8 && y <= 10 && (lx === 6 || lx === 10)) return [20, 20, 30, 255]; + if (row === 2 && y >= 8 && y <= 10 && lx === 11) return [20, 20, 30, 255]; + return [240, 200, 160, 255]; + } + // body + if (y >= 14 && y < 25 && lx >= 4 && lx < 12) return [...body, 255] as [number, number, number, number]; + // legs alternate + if (y >= 25 && y < 31) { + const spread = ph ? 3 : 1; + if (Math.abs(lx - (8 - spread)) < 2 || Math.abs(lx - (8 + spread)) < 2) return [40, 48, 60, 255]; + } + return [0, 0, 0, 0]; + }); +} +await Bun.write(DIR + "hero.png", walkerSheet([66, 184, 131])); +await Bun.write(DIR + "buddy.png", walkerSheet([214, 130, 60])); + +// breakout court: 240x160 dark hall with side rails +await Bun.write( + DIR + "court.png", + img(240, 160, (x, y) => { + if (x < 20 || x >= 220) return [30, 34, 52, 255]; + if (y < 12) return [30, 34, 52, 255]; + const g = 18 + Math.floor((y / 160) * 14); + return [g - 8, g - 4, g + 10, 255]; + }), +); + +// action runner: 32x32 x 8 frames (idle, run x4, jump, shoot, hurt) +await Bun.write( + DIR + "runner.png", + img(256, 32, (x, y) => { + const f = Math.floor(x / 32); + const lx = x % 32; + const lean = f >= 1 && f <= 4 ? 2 : 0; // run frames lean forward + // head + if (Math.hypot(lx - 16 - lean, y - 8) < 5) { + if (f === 7) return [220, 120, 120, 255]; // hurt flash + return [240, 200, 160, 255]; + } + // body + if (y >= 13 && y < 23 && lx >= 12 + lean && lx < 20 + lean) + return f === 6 ? [230, 210, 90, 255] : [90, 200, 220, 255]; // shoot pose pops + // gun arm on shoot frame + if (f === 6 && y >= 15 && y < 18 && lx >= 20 && lx < 28) return [40, 48, 60, 255]; + // legs: 4-phase run cycle, tucked when jumping (f5) + if (y >= 23 && y < 31) { + if (f === 5) { + if (y < 27 && (Math.abs(lx - 13) < 2 || Math.abs(lx - 19) < 2)) return [40, 48, 60, 255]; + return [0, 0, 0, 0]; + } + const phase = f >= 1 && f <= 4 ? f - 1 : 0; + const spread = [1, 4, 1, 4][phase] + (f >= 1 && f <= 4 ? 1 : 0); + const flip = phase >= 2 ? -1 : 1; + if (Math.abs(lx - (16 - spread * flip)) < 2 || Math.abs(lx - (16 + spread * flip)) < 2) + return [40, 48, 60, 255]; + } + return [0, 0, 0, 0]; + }), +); + +// action thug: 32x32 x 4 frames (idle, walk0, walk1, attack) +await Bun.write( + DIR + "thug.png", + img(128, 32, (x, y) => { + const f = Math.floor(x / 32); + const lx = x % 32; + if (Math.hypot(lx - 16, y - 9) < 5.5) return f === 3 ? [255, 120, 90, 255] : [200, 160, 130, 255]; + if (y >= 14 && y < 24 && lx >= 11 && lx < 21) return [160, 60, 70, 255]; + if (f === 3 && y >= 16 && y < 19 && lx >= 21 && lx < 30) return [40, 48, 60, 255]; // lunge arm + if (y >= 24 && y < 31) { + const spread = f === 1 ? 4 : f === 2 ? 1 : 2; + if (Math.abs(lx - (16 - spread)) < 2.5 || Math.abs(lx - (16 + spread)) < 2.5) return [50, 40, 46, 255]; + } + return [0, 0, 0, 0]; + }), +); + +// action drone: 16x16 x 4 frames +await Bun.write( + DIR + "dronex.png", + img(64, 16, (x, y) => { + const f = Math.floor(x / 16); + const lx = x % 16; + if (y >= 6 && y < 11 && lx >= 3 && lx < 13) return [120, 130, 150, 255]; // hull + if (y === 5 && ((f % 2 === 0 && (lx === 2 || lx === 13)) || (f % 2 === 1 && (lx === 4 || lx === 11)))) + return [220, 230, 240, 255]; // rotor blur alternates + if (f === 3 && y >= 11 && y < 13 && lx >= 7 && lx < 9) return [255, 90, 70, 255]; // firing eye + if (y >= 8 && y < 10 && lx >= 6 && lx < 10) return [255, 170, 60, 255]; // sensor band + return [0, 0, 0, 0]; + }), +); + +// smoke music track: 2s of square-wave arpeggio, s8 mono @ 13379 Hz +{ + const RATE = 13379; + const secs = 2; + const pcm = new Int8Array(RATE * secs); + const notes = [220, 277.18, 329.63, 440]; // A minor-ish arp + for (let i = 0; i < pcm.length; i++) { + const t = i / RATE; + const f = notes[Math.floor(t * 8) % notes.length]; + const sq = Math.sign(Math.sin(2 * Math.PI * f * t)); + const env = 1 - ((t * 8) % 1) * 0.6; + pcm[i] = Math.round(sq * 52 * env); + } + await Bun.write(DIR + "smoke-track.raw", new Uint8Array(pcm.buffer)); +} + +console.log("placeholder art written to edge/test/art/"); diff --git a/edge/test/ppm2png.ts b/edge/test/ppm2png.ts new file mode 100644 index 0000000..a994bdb --- /dev/null +++ b/edge/test/ppm2png.ts @@ -0,0 +1,34 @@ +// edge/test/ppm2png.ts — convert the harness's P6 PPM screenshots to PNG. +// bun test/ppm2png.ts dist/shots/*.ppm +import { encodePng } from "../compiler/png.ts"; + +export async function ppm2png(path: string): Promise { + const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()); + // P6\n \n255\n + let o = 0; + const token = (): string => { + while (bytes[o] === 32 || bytes[o] === 10 || bytes[o] === 13 || bytes[o] === 9) o++; + let s = ""; + while (o < bytes.length && ![32, 10, 13, 9].includes(bytes[o])) s += String.fromCharCode(bytes[o++]); + return s; + }; + if (token() !== "P6") throw new Error("not P6: " + path); + const w = parseInt(token()); + const h = parseInt(token()); + token(); // maxval + o++; // single whitespace after maxval + const rgba = new Uint8Array(w * h * 4); + for (let i = 0; i < w * h; i++) { + rgba[i * 4] = bytes[o + i * 3]; + rgba[i * 4 + 1] = bytes[o + i * 3 + 1]; + rgba[i * 4 + 2] = bytes[o + i * 3 + 2]; + rgba[i * 4 + 3] = 255; + } + const out = path.replace(/\.ppm$/, ".png"); + await Bun.write(out, encodePng(rgba, w, h)); + return out; +} + +if (import.meta.main) { + for (const p of process.argv.slice(2)) console.log(await ppm2png(p)); +} diff --git a/edge/test/smoke-film.ts b/edge/test/smoke-film.ts new file mode 100644 index 0000000..d37c87f --- /dev/null +++ b/edge/test/smoke-film.ts @@ -0,0 +1,220 @@ +// edge/test/smoke-film.ts — 2-scene pipeline smoke test (placeholder art). +// Exercises: gradient raster, parallax pan, sprite walk, captions (CJK+ASCII), +// dialog, choice branch, control walk, mash+counter, letterbox, mosaic, fades, +// affine zoom/spin, wave raster, scene transition, gotoScene loop guard. + +import { + defineFilm, defineScene, cue, image, gradient, sprite, track, + fadeIn, fadeOut, wait, waitA, waitTweens, caption, captionClear, dialog, choice, + pan, letterbox, mosaicTo, shake, alpha, zoom, spinTo, show, hide, animate, + moveTo, walkTo, control, mash, counter, affineOn, sfx, gotoScene, setFlag, hasFlag, + rasterWave, rasterOff, world, breakout, meterShow, meterHide, setVar, varEq, walk, face, + action, music, +} from "@pocketjs/edge"; + +const street = defineScene({ + id: "street", + sky: gradient("#0a1430", "#2a4a7a", "#e8965a"), + far: image("art/hills.png", { scroll: 0.35 }), + main: image("art/street.png", { wide: true }), + actors: { + walker: sprite("art/walker.png", { w: 32, h: 32, frames: 2, fps: 8, at: [60, 100] }), + }, + play: cue(function* () { + yield letterbox(16, 1); + yield fadeIn(30); + yield caption("chip", "1990年代 · 某条街"); + yield wait(20); + yield show("walker"); + yield letterbox(0, 30); + yield pan(144, 120, "inout"); + yield walkTo("walker", 240, 120); + yield caption("sub", "他沿着街走。Hello GBA!"); + yield waitA(); + yield captionClear("all"); + yield dialog("路人", "要不要自己走一段?"); + const c = yield choice(["好啊", "算了"]); + if (c === 0) { + yield setFlag("walked"); + yield control("walker", 330, 1.5); + } else { + yield walkTo("walker", 330, 90); + } + yield caption("sub", "按 A 收集星星!"); + yield counter("stars", 200, 24); + yield mash("stars", 5); + yield captionClear("all"); + yield shake(3, 40); + yield mosaicTo(12, 40); + yield fadeOut(30); + }), +}); + +const dream = defineScene({ + id: "dream", + sky: gradient("#050510", "#1a1035"), + backdrop: "#050510", + wave: { layer: "main", amp: 3 }, + main: image("art/hills.png"), + actors: { + emblem: sprite("art/emblem.png", { w: 32, h: 32, at: [120, 70], screen: true }), + }, + play: cue(function* () { + yield fadeIn(40); + yield caption("card", "梦境 DREAM"); + yield wait(30); + yield show("emblem", 112, 60); + yield affineOn("emblem"); + yield zoom(0.3, 1); + yield zoom(1.5, 60, "out"); + yield spinTo(360, 90, "inout"); + yield waitTweens(); + yield rasterOff(); + yield alpha(8, 8, 40); + yield waitA(); + if (yield hasFlag("walked")) { + yield caption("sub", "你走过了那条街。"); + } else { + yield caption("sub", "旁观也是一种走法。"); + } + yield waitA(); + yield captionClear("all"); + yield fadeOut(30); + }), +}); + +// world scene: grid walking, NPC talk, examine spot, exit door +const room = defineScene({ + id: "room", + main: image("art/room.png"), + backdrop: "#101018", + actors: { + hero: sprite("art/hero.png", { w: 16, h: 32, frames: 6, walkFpd: 2 }), + buddy: sprite("art/buddy.png", { w: 16, h: 32, frames: 6, walkFpd: 2 }), + }, + world: { + grid: [ + "####################", + "#..................#", + "#.........w........#", + "#..................#", + "#..##..............#", + "#..................#", + "#..................#", + "#.........p........#", + "#..................#", + "#..................#", + "#..................#", + "#..................#", + "#..................#", + "#.........d........#", + "####################", + ], + player: { actor: "hero", at: "p", dir: "down" }, + npcs: { + buddy: { + actor: "buddy", + at: "w", + dir: "down", + talk: cue(function* () { + // the branch matters: sub-cue jump targets must be blob-absolute + if (yield varEq("talked", 1)) { + yield dialog("BUDDY", "Again? Go on, then."); + } else { + yield dialog("BUDDY", "Grid walking works.\nBench, then the door."); + yield setVar("talked", 1); + } + }), + }, + }, + spots: { + bench: { + at: [3, 4, 2, 1], + run: cue(function* () { + yield caption("sub", "A sturdy workbench."); + yield waitA(); + yield captionClear("all"); + yield setVar("benched", 1); + }), + }, + }, + exits: { door: { at: "d", value: 7 } }, + }, + play: cue(function* () { + yield fadeIn(20); + yield caption("chip", "WORLD TEST"); + const exit = yield world(); + yield captionClear("all"); + yield setVar("exit_code", exit); + yield fadeOut(20); + }), +}); + +// breakout + meter scene +const arcade = defineScene({ + id: "arcade", + main: image("art/court.png"), + backdrop: "#0a0c14", + play: cue(function* () { + yield fadeIn(15); + yield caption("sub", "BREAKOUT — A to launch"); + yield setVar("mood", 6); + yield meterShow(0, "mood", 24, 4, 8); + const cleared = yield breakout(3, 2, 420); + yield setVar("cleared", cleared); + yield meterHide(0); + yield captionClear("all"); + yield dialog("SMOKE", "Night over. Bricks counted."); + yield fadeOut(15); + yield gotoScene("alley"); + }), +}); + +// action scene: run-and-gun with a gate, thug melee, gunner bursts, a drone, +// the sandevistan slow-mo, and a PCM music track streaming underneath +const alley = defineScene({ + id: "alley", + sky: gradient("#0a0a20", "#242455", "#583a68"), + far: image("art/hills.png", { scroll: 0.35 }), + main: image("art/street.png", { wide: true }), + backdrop: "#0a0a20", + actors: { + runner: sprite("art/runner.png", { w: 32, h: 32, frames: 8 }), + thug: sprite("art/thug.png", { w: 32, h: 32, frames: 4 }), + dronex: sprite("art/dronex.png", { w: 16, h: 16, frames: 4 }), + }, + action: { + player: { actor: "runner", hp: 6, sande: 24 }, + ground: 128, + length: 800, + gates: [{ x: 320, wave: 1 }], + spawns: [ + { type: "thug", actor: "thug", x: 280, hp: 2, wave: 1 }, + { type: "gunner", actor: "thug", x: 440, hp: 2 }, + { type: "drone", actor: "dronex", x: 580, hp: 2 }, + ], + }, + play: cue(function* () { + yield music("smoke"); + yield fadeIn(15); + yield caption("chip", "ACTION TEST"); + yield meterShow(0, "hp", 16, 4, 6); + yield meterShow(1, "sande", 16, 14, 24); + const r = yield action(); + yield setVar("act_result", r); + yield music("off"); + yield meterHide(0); + yield meterHide(1); + yield captionClear("all"); + yield fadeOut(15); + }), +}); + +// room first: the engine e2e drives the new world/minigame/action paths with +// the shortest possible boot; street/dream keep covering the cine vocabulary. +// arcade jumps to alley explicitly; alley falls through to street in film order. +export default defineFilm({ + title: "EDGE SMOKE", + scenes: [room, arcade, alley, street, dream], + music: { smoke: track("art/smoke-track.raw", { loop: true }) }, +}); diff --git a/saga/.gitignore b/saga/.gitignore new file mode 100644 index 0000000..0cbab80 --- /dev/null +++ b/saga/.gitignore @@ -0,0 +1,4 @@ +dist/ +runtime/gen_data.c +test/art/ +*.__saga.*.mjs diff --git a/saga/README.md b/saga/README.md new file mode 100644 index 0000000..3ae50fb --- /dev/null +++ b/saga/README.md @@ -0,0 +1,90 @@ +# @pocketjs/saga + +**Author interactive pixel-art biographies in TypeScript; ship a real GBA ROM with walkable worlds, encounters and cinematics.** + +`@pocketjs/saga` is the generalized descendant of `@pocketjs/cine`. Where cine plays a montage *at* you (with playable beats), saga hands you the D-pad: **top-down world scenes** you walk through Pokémon-style — grid stepping, collision, NPCs that turn to face you, examine spots, exit doors — plus **encounter scenes** (portraits + conviction meters driven by dialogue choices) and a **playable Breakout set piece**, all sharing cine's full cinematic vocabulary (per-scanline raster gradients, BLDCNT fades, WIN0 letterbox, affine OBJs, typewriter captions, PSG blips) and the same partial-evaluation discipline: the declaration zone runs at build time, `cue(function* () { ... })` bodies are lowered from TS AST to bytecode for a suspendable cue VM. + +The first game is **REALITY DISTORTION — Part One**, an English-language fan tribute to Steve Jobs from Paul Jobs' workbench to the Macintosh launch (Jan 24, 1984). Every dated event follows a source-cited research dossier (`game/dossier.md`); disputed history (the garage myth, the Breakout bonus figure) is either avoided or framed exactly as disputed. Most dialogue is original fan writing; a handful of documented lines (the Hewlett call pitch, "gold mine" at PARC, the Carmel retreat sayings, the sugared-water question as Sculley's memoir records it, the Macintosh's own 1984 speech) appear verbatim, and the credits say so. + +## The game + +Boot into a chapter menu or play straight through (~10 minutes): + +1. **The Workbench** — Mountain View, early 60s. *World.* Walk the garage as a kid; Dad marks off your half of the bench. +2. **The Call** — Los Altos, 1968. A phone book, Bill Hewlett, and the documented pitch, word for word. +3. **The Blue Box** — Berkeley, 1971. Woz's all-digital box; "No blue boxes, no Apple." +4. **The Letterform** — Reed, 1972. Dropped out, dropped in: Palladino's calligraphy room. +5. **Breakout** — Atari, 1975. *Minigame.* Keep the prototype alive until dawn — real bricks, real paddle. The payment dispute is presented as exactly that: disputed. +6. **Fifty Boards** — Los Altos, April 1976. *World + encounter.* Terrell ordered 50 assembled boards, COD; talk the parts man into net-30 (meter battle) and ship in 29 days. +7. **The Faire** — San Francisco, 1977. Three finished Apple IIs and a bluff of empty cases. +8. **The Goldmine** — Xerox PARC, Dec 1979. *World.* Find the Alto. Shout the documented shout. +9. **Sugared Water** — San Remo terrace, 1983. *Encounter.* Build conviction before you ask the question, or Sculley waves you off. +10. **Pirates** — Bandley 3, Aug 1983. *World.* The flag Capps sewed and Kare painted; the prototype that says hello. +11. **Hello** — Flint Center, Jan 24, 1984. Mash the applause, pull it from the bag, and let the Macintosh speak for itself. + +## Authoring model + +World scenes are declared as art + an ASCII grid; everything else is the same cue discipline as cine: + +```ts +const garage = defineScene({ + id: "garage76", + main: image("art/map_garage76.png"), // 320x240 top-down map (PixelLab) + actors: { hero: sprite("art/spr_hero.png", { w: 32, h: 32, frames: 12, walkFpd: 4 }) }, + world: { + grid: [ "####################", + "########ddd#########", // # solid · . floor · letters name cells + "#..w......p........#", /* ... 20x15 cells = the 320x240 image */ ], + player: { actor: "hero", at: "p", dir: "up" }, + npcs: { woz: { actor: "woz", at: "w", talk: cue(function* () { /* dialog */ }) } }, + spots: { phone: { at: [7, 3, 1, 3], run: cue(function* () { /* examine */ }) } }, + exits: { door: { at: "d", value: 1 } }, + }, + play: cue(function* () { + yield fadeIn(40); + const exit = yield world(); // blocks: player roams until an exit trigger + yield fadeOut(40); + }), +}); +``` + +NPC/spot cues interrupt the roam and return to it; `warp`/`face`/`walk` script the grid from cutscenes; `meterShow` + `choice` loops make persuasion encounters; `breakout(rows, lives, frames)` blocks until the night is over and pushes the bricks cleared. + +## Build & run + +```bash +# prerequisites: bun, arm-none-eabi-gcc + binutils; mgba for the headless tests +cd saga +bun run build # dist/reality-distortion.gba +bash play.sh # build + open in mGBA.app +bun run test # headless E2E: full playthrough via mgba +bun run test:engine # engine E2E on the placeholder smoke film +bun run smoke # build the smoke film (no PixelLab needed) +bun run art # (re)generate art via PixelLab (cached; needs PIXELLAB_API_KEY) +bun pixellab/walkers.ts # assemble walker sheets (hero gets real 4-frame walk cycles) +``` + +Controls: D-pad to walk / pick, A to talk, examine, confirm, launch. + +## Engine layout + +``` +spec/saga.ts binary contract: ops, tween targets, world/trigger tables, + VRAM plan, debug block (mirrored to runtime/saga_gen.h) +dsl/index.ts defineFilm/defineScene (+world decl) + residual op vocabulary +compiler/ evaluate -> residualize (TS AST -> bytecode, multi-cue tables) + -> assets (15-color quantize, flip dedup, 64x64 quadrant maps, + walker sheets, glyph store) -> emit gen_data.c -> rom +runtime/ fixed C: cue VM + world.c (grid walker, NPC/trigger dispatch, + both-axis camera) + breakout.c + fx/raster/caption/obj/sfx +pixellab/ pixflux client + game prompt sheet + walkers.ts + (/animate-with-text walk cycles at 64px, 2x round trip) +game/ reality-distortion.ts + dossier.md + art/ (committed PNGs) +test/ engine-e2e.ts (24 asserts), game e2e, smoke film +``` + +E2E drives the same debug block contract as aot/cine (EWRAM `0x02000000`) through `aot/test/harness/mgba_runner` — plus world fields: player cell/facing, bricks left, scene kind. + +## Fan-work notes + +Unaffiliated tribute; no trademarks or trade dress in generated art (prompts are franchise-neutral — "a beige home computer", "a black pirate flag"). Real names appear only in documented historical context. `game/dossier.md` carries the full source list and the do-not-state-as-fact ledger. diff --git a/saga/compiler/assets.ts b/saga/compiler/assets.ts new file mode 100644 index 0000000..f27ad2b --- /dev/null +++ b/saga/compiler/assets.ts @@ -0,0 +1,404 @@ +// saga/compiler/assets.ts — turn PNGs into GBA-native data: median-cut +// 15-color palettes, 4bpp tiles with H/V-flip dedup, tilemaps, OBJ sheets, +// per-scanline gradient tables, and the built-in UI tiles (box/cursor/digits/ +// A-prompt) rendered from Unifont. + +import { decodePng, type DecodedImage } from "./png.ts"; +import { unifontGlyph, halfcellPixels } from "./cjk.ts"; +import { rgb555, hex555, UI_INK, UI_BOX, UI_ACCENT, UI_SHADOW } from "../spec/saga.ts"; + +export interface Quantized { + /** palette[0] is transparent; entries 1..n are BGR555. */ + pal555: number[]; + indices: Uint8Array; // per pixel palette index (0 = transparent) + w: number; + h: number; +} + +export async function loadPng(path: string): Promise { + const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()); + return decodePng(bytes); +} + +/** Median-cut to <= maxColors opaque colors (+ index 0 transparent). */ +export function quantize(img: DecodedImage, maxColors = 15): Quantized { + const { width: w, height: h, rgba } = img; + const pixels: number[] = []; // packed rgb of opaque pixels + for (let i = 0; i < w * h; i++) { + if (rgba[i * 4 + 3] >= 128) pixels.push((rgba[i * 4] << 16) | (rgba[i * 4 + 1] << 8) | rgba[i * 4 + 2]); + } + // unique colors + const uniq = [...new Set(pixels)]; + let pal: number[]; + if (uniq.length <= maxColors) { + pal = uniq; + } else { + interface Box { colors: number[] } + const boxes: Box[] = [{ colors: uniq }]; + while (boxes.length < maxColors) { + // split the box with the largest channel range + let bi = -1; + let bch = 0; + let brange = -1; + for (let i = 0; i < boxes.length; i++) { + const cs = boxes[i].colors; + if (cs.length < 2) continue; + for (let ch = 0; ch < 3; ch++) { + const sh = 16 - ch * 8; + let lo = 255, hi = 0; + for (const c of cs) { + const v = (c >> sh) & 0xff; + if (v < lo) lo = v; + if (v > hi) hi = v; + } + if (hi - lo > brange) { + brange = hi - lo; + bi = i; + bch = sh; + } + } + } + if (bi < 0) break; + const cs = boxes[bi].colors.sort((a, b) => ((a >> bch) & 0xff) - ((b >> bch) & 0xff)); + const mid = cs.length >> 1; + boxes.splice(bi, 1, { colors: cs.slice(0, mid) }, { colors: cs.slice(mid) }); + } + // box average (weighted by pixel counts) + const counts = new Map(); + for (const p of pixels) counts.set(p, (counts.get(p) ?? 0) + 1); + pal = boxes.map(({ colors }) => { + let r = 0, g = 0, b = 0, n = 0; + for (const c of colors) { + const k = counts.get(c) ?? 1; + r += ((c >> 16) & 0xff) * k; + g += ((c >> 8) & 0xff) * k; + b += (c & 0xff) * k; + n += k; + } + return n ? ((Math.round(r / n) << 16) | (Math.round(g / n) << 8) | Math.round(b / n)) : 0; + }); + } + const pal555 = [0, ...pal.map((c) => rgb555((c >> 16) & 0xff, (c >> 8) & 0xff, c & 0xff))]; + // nearest-color mapping + const cache = new Map(); + const indices = new Uint8Array(w * h); + for (let i = 0; i < w * h; i++) { + if (rgba[i * 4 + 3] < 128) { + indices[i] = 0; + continue; + } + const c = (rgba[i * 4] << 16) | (rgba[i * 4 + 1] << 8) | rgba[i * 4 + 2]; + let best = cache.get(c); + if (best === undefined) { + let bd = Infinity; + best = 1; + for (let p = 0; p < pal.length; p++) { + const dr = ((c >> 16) & 0xff) - ((pal[p] >> 16) & 0xff); + const dg = ((c >> 8) & 0xff) - ((pal[p] >> 8) & 0xff); + const db = (c & 0xff) - (pal[p] & 0xff); + const d = dr * dr * 3 + dg * dg * 6 + db * db; + if (d < bd) { + bd = d; + best = p + 1; + } + } + cache.set(c, best); + } + indices[i] = best; + } + return { pal555, indices, w, h }; +} + +/** 4bpp tile from a 64-entry palette-index array. */ +export function tile4(px: ArrayLike): Uint8Array { + const out = new Uint8Array(32); + for (let row = 0; row < 8; row++) + for (let c = 0; c < 4; c++) { + out[row * 4 + c] = (px[row * 8 + c * 2] & 0xf) | ((px[row * 8 + c * 2 + 1] & 0xf) << 4); + } + return out; +} + +function tileKey(t: Uint8Array): string { + return Buffer.from(t).toString("base64"); +} +function flipH(px: number[]): number[] { + const o = new Array(64); + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) o[y * 8 + x] = px[y * 8 + (7 - x)]; + return o; +} +function flipV(px: number[]): number[] { + const o = new Array(64); + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) o[y * 8 + x] = px[(7 - y) * 8 + x]; + return o; +} + +export interface TiledLayer { + tiles: Uint8Array[]; // deduped, NOT including the shared blank tile 0 + /** map entries: tileIndex (1-based within this layer) | flip bits; 0 = blank */ + cells: { tile: number; hflip: boolean; vflip: boolean }[]; + cols: number; + rows: number; +} + +/** Cut an indexed image into deduped 8x8 tiles (H/V flip aware). */ +export function tileLayer(q: Quantized): TiledLayer { + const cols = Math.ceil(q.w / 8); + const rows = Math.ceil(q.h / 8); + const tiles: Uint8Array[] = []; + const seen = new Map(); + const cells: TiledLayer["cells"] = []; + for (let ty = 0; ty < rows; ty++) { + for (let tx = 0; tx < cols; tx++) { + const px = new Array(64).fill(0); + let allZero = true; + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + const sx = tx * 8 + x; + const sy = ty * 8 + y; + const v = sx < q.w && sy < q.h ? q.indices[sy * q.w + sx] : 0; + px[y * 8 + x] = v; + if (v) allZero = false; + } + if (allZero) { + cells.push({ tile: 0, hflip: false, vflip: false }); + continue; + } + const key = tileKey(tile4(px)); + const hit = seen.get(key); + if (hit) { + cells.push({ tile: hit.idx, hflip: hit.h, vflip: hit.v }); + continue; + } + const idx = tiles.length + 1; + tiles.push(tile4(px)); + seen.set(key, { idx, h: false, v: false }); + const fh = flipH(px); + const fv = flipV(px); + const fhv = flipV(fh); + for (const [p, hh, vv] of [ + [fh, true, false], + [fv, false, true], + [fhv, true, true], + ] as const) { + const k = tileKey(tile4(p)); + if (!seen.has(k)) seen.set(k, { idx, h: hh, v: vv }); + } + cells.push({ tile: idx, hflip: false, vflip: false }); + } + } + return { tiles, cells, cols, rows }; +} + +/** Build a screenblock map from a tiled layer. + * mapSz 0 = 32x32 (1 SBB), 1 = 64x32 (2 SBBs), 2 = 64x64 (4 SBBs TL,TR,BL,BR). */ +export function buildMap( + layer: TiledLayer, + mapSz: 0 | 1 | 2, + tileBase: number, + palbank: number, + rowOff = 0, +): Uint16Array { + const mapCols = mapSz ? 64 : 32; + const mapRows = mapSz === 2 ? 64 : 32; + const out = new Uint16Array(mapCols * mapRows); + for (let r0 = 0; r0 < layer.rows && r0 + rowOff < mapRows; r0++) { + const r = r0 + rowOff; + for (let c = 0; c < layer.cols && c < mapCols; c++) { + const cell = layer.cells[r0 * layer.cols + c]; + if (cell.tile === 0) continue; + let se = ((tileBase + cell.tile - 1) & 0x3ff) | ((palbank & 0xf) << 12); + if (cell.hflip) se |= 0x400; + if (cell.vflip) se |= 0x800; + // multi-screenblock layouts: quadrants of 32x32 + const sb = (r >> 5) * (mapCols >> 5) + (c >> 5); + out[sb * 1024 + (r & 31) * 32 + (c & 31)] = se; + } + } + return out; +} + +/** OBJ sheet: horizontal frame strip -> 4bpp tiles in 1D frame-major order. */ +export function tileObjSheet(q: Quantized, fw: number, fh: number, frames: number): Uint8Array { + const tw = fw / 8; + const th = fh / 8; + const out = new Uint8Array(frames * tw * th * 32); + let o = 0; + for (let f = 0; f < frames; f++) { + for (let ty = 0; ty < th; ty++) { + for (let tx = 0; tx < tw; tx++) { + const px = new Array(64).fill(0); + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + const sx = f * fw + tx * 8 + x; + const sy = ty * 8 + y; + if (sx < q.w && sy < q.h) px[y * 8 + x] = q.indices[sy * q.w + sx]; + } + out.set(tile4(px), o); + o += 32; + } + } + } + return out; +} + +/** 160-entry per-scanline gradient (multi-stop, lerp in RGB). */ +export function gradientTable(stops: string[]): Uint16Array { + const rgb = stops.map((s) => { + const h = s.replace("#", ""); + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; + }); + const out = new Uint16Array(160); + const segs = rgb.length - 1; + for (let y = 0; y < 160; y++) { + const t = (y / 159) * segs; + const si = Math.min(segs - 1, Math.floor(t)); + const f = t - si; + const a = rgb[si]; + const b = rgb[si + 1]; + out[y] = rgb555( + Math.round(a[0] + (b[0] - a[0]) * f), + Math.round(a[1] + (b[1] - a[1]) * f), + Math.round(a[2] + (b[2] - a[2]) * f), + ); + } + return out; +} + +// --- built-in UI assets ----------------------------------------------------------- + +/** UI BG palette bank 15 colors (index -> BGR555). */ +export function uiPalette(): number[] { + const bank = new Array(16).fill(0); + bank[UI_INK] = hex555("#f2f5f7"); + bank[UI_BOX] = hex555("#141c2a"); + bank[UI_ACCENT] = hex555("#42b883"); + bank[UI_SHADOW] = hex555("#5a6478"); + return bank; +} + +/** 4 fixed BG tiles: blank, box fill, accent underline, choice cursor. */ +export function uiBgTiles(): Uint8Array { + const out = new Uint8Array(4 * 32); + // 0: blank (all transparent) + // 1: box fill + out.set(tile4(new Array(64).fill(UI_BOX)), 32); + // 2: accent underline: box with a 2px green line at the bottom + { + const px = new Array(64).fill(UI_BOX); + for (let x = 0; x < 8; x++) { + px[5 * 8 + x] = UI_ACCENT; + px[6 * 8 + x] = UI_ACCENT; + } + out.set(tile4(px), 64); + } + // 3: cursor arrow (ink on box) + { + const px = new Array(64).fill(UI_BOX); + for (let y = 1; y < 7; y++) { + const span = y < 4 ? y : 7 - y; + for (let x = 1; x <= 1 + span; x++) px[y * 8 + x] = UI_ACCENT; + } + out.set(tile4(px), 96); + } + return out; +} + +/** OBJ UI sheet layout (tile offsets from OBJ_UI_BASE): + * 0-3 A-prompt 16x16, 4-23 digits 0-9 as 8x16, 24 meter seg full, + * 25 meter seg empty, 26-27 brick 16x8, 28 ball 8x8, 29-32 paddle 32x8. */ +export function uiObjTiles(): Uint8Array { + const tiles: Uint8Array[] = []; + // A-prompt: dark disc, green rim, white 'A' + { + const grid = new Array(256).fill(0); + const cx = 7.5, cy = 7.5; + for (let y = 0; y < 16; y++) + for (let x = 0; x < 16; x++) { + const d = Math.hypot(x - cx, y - cy); + if (d <= 6.2) grid[y * 16 + x] = UI_BOX; + else if (d <= 7.6) grid[y * 16 + x] = UI_ACCENT; + } + const glyph = unifontGlyph(0x41); // 'A' + const [top, bottom] = halfcellPixels(glyph, 0, UI_INK, 99); + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + if (top[y * 8 + x] === UI_INK && y >= 2) grid[(y + 1) * 16 + (x + 4)] = UI_INK; + if (bottom[y * 8 + x] === UI_INK && y <= 5) grid[(y + 9) * 16 + (x + 4)] = UI_INK; + } + for (const [ox, oy] of [ + [0, 0], + [8, 0], + [0, 8], + [8, 8], + ]) { + const px = new Array(64); + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) px[y * 8 + x] = grid[(oy + y) * 16 + (ox + x)]; + tiles.push(tile4(px)); + } + } + // digits 0-9: unifont halfwidth, ink with shadow, transparent bg, 8x16 (2 tiles) + for (let d = 0; d <= 9; d++) { + const glyph = unifontGlyph(0x30 + d); + const [top, bottom] = halfcellPixels(glyph, 0, UI_INK, 0); + for (const half of [top, bottom]) { + // drop shadow: shift down-right in UI_SHADOW + const px = new Array(64).fill(0); + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + if (half[y * 8 + x] === UI_INK) px[y * 8 + x] = UI_INK; + } + tiles.push(tile4(px)); + } + } + // meter segments: full (accent fill, ink rim) and empty (shadow rim) + { + const full = new Array(64).fill(UI_ACCENT); + const empty = new Array(64).fill(0); + for (let i = 0; i < 8; i++) { + full[i] = full[56 + i] = UI_INK; + full[i * 8] = full[i * 8 + 7] = UI_INK; + empty[i] = empty[56 + i] = UI_SHADOW; + empty[i * 8] = empty[i * 8 + 7] = UI_SHADOW; + empty[i * 8 + 1] = empty[i * 8 + 1] || UI_BOX; + } + tiles.push(tile4(full), tile4(empty)); + } + // breakout brick 16x8: accent fill, ink top highlight, 1px gap right/bottom + for (const half of [0, 1]) { + const px = new Array(64).fill(UI_ACCENT); + for (let x = 0; x < 8; x++) { + px[x] = UI_INK; // top highlight + px[7 * 8 + x] = 0; // bottom gap + px[6 * 8 + x] = UI_SHADOW; + } + if (half === 1) { + for (let y = 0; y < 8; y++) px[y * 8 + 7] = 0; // right gap + } + tiles.push(tile4(px)); + } + // ball 8x8: ink disc + { + const px = new Array(64).fill(0); + for (let y = 0; y < 8; y++) + for (let x = 0; x < 8; x++) { + const d = Math.hypot(x - 3.5, y - 3.5); + if (d <= 3.2) px[y * 8 + x] = UI_INK; + } + tiles.push(tile4(px)); + } + // paddle 32x8: shadow slab with ink top edge, rounded ends + for (let t = 0; t < 4; t++) { + const px = new Array(64).fill(0); + for (let y = 2; y < 7; y++) + for (let x = 0; x < 8; x++) { + const gx = t * 8 + x; + if ((gx === 0 || gx === 31) && (y === 2 || y === 6)) continue; + px[y * 8 + x] = y === 2 ? UI_INK : y === 6 ? UI_BOX : UI_SHADOW; + } + tiles.push(tile4(px)); + } + const out = new Uint8Array(tiles.length * 32); + tiles.forEach((t, i) => out.set(t, i * 32)); + return out; +} diff --git a/saga/compiler/cjk.ts b/saga/compiler/cjk.ts new file mode 100644 index 0000000..827fb0c --- /dev/null +++ b/saga/compiler/cjk.ts @@ -0,0 +1,89 @@ +// saga/compiler/cjk.ts — GNU Unifont loader (copied from aot; same font asset). +// +// Unifont ships every BMP glyph as a 16px-tall 1-bit bitmap in .hex format: +// one line per codepoint, "XXXX:", where halfwidth glyphs are 8px wide +// (32 hex digits) and fullwidth glyphs 16px wide (64 hex digits). That makes +// it the single cross-target glyph source: the compiler bakes only the glyphs +// a game actually uses, in each target's native tile encoding. +// +// Unifont is dual-licensed (GNU GPLv2+ with the font embedding exception / +// SIL OFL 1.1); embedding rendered glyphs in a ROM is explicitly permitted. + +import { gunzipSync } from "bun"; +import { readFileSync } from "node:fs"; + +const HEX_PATH = new URL("../../assets/fonts/unifont-16.0.04.hex.gz", import.meta.url).pathname; + +export interface UnifontGlyph { + /** 8 or 16 pixels wide; always 16 tall. */ + width: 8 | 16; + /** 16 rows; each row is a bitmask, MSB = leftmost pixel. */ + rows: Uint16Array; // length 16 +} + +let table: Map | null = null; + +function load(): Map { + if (table) return table; + const text = new TextDecoder().decode(gunzipSync(readFileSync(HEX_PATH))); + table = new Map(); + for (const line of text.split("\n")) { + const colon = line.indexOf(":"); + if (colon <= 0) continue; + const cp = parseInt(line.slice(0, colon), 16); + const hex = line.slice(colon + 1).trim(); + if (hex.length !== 32 && hex.length !== 64) continue; + const width = hex.length === 32 ? 8 : 16; + const rows = new Uint16Array(16); + const digitsPerRow = width / 4; + for (let r = 0; r < 16; r++) { + rows[r] = parseInt(hex.slice(r * digitsPerRow, (r + 1) * digitsPerRow), 16); + } + table.set(cp, { width: width as 8 | 16, rows }); + } + return table; +} + +/** Glyph for a codepoint, or null if Unifont has none. */ +export function unifontGlyph(cp: number): UnifontGlyph | null { + return load().get(cp) ?? null; +} + +/** True if the char renders fullwidth (2 halfcells). ASCII is halfwidth. */ +export function isFullwidth(ch: string): boolean { + const cp = ch.codePointAt(0)!; + if (cp <= 0x7e) return false; + const g = unifontGlyph(cp); + if (!g) return true; // unknown chars reserve a full cell (rendered blank) + return g.width === 16; +} + +/** + * Rasterize one halfcell (8x16 column) of a glyph into two stacked 8x8 + * palette-index tiles: [top 64 px, bottom 64 px], row-major. + * + * `half` selects the left (0) or right (1) 8px column of a 16px glyph. + * `ink`/`bg` are the palette indices to write. + */ +export function halfcellPixels( + glyph: UnifontGlyph | null, + half: 0 | 1, + ink: number, + bg: number, +): [number[], number[]] { + const top = new Array(64).fill(bg); + const bottom = new Array(64).fill(bg); + if (glyph) { + const shiftBase = glyph.width - 8 * (half + 1); // bits below the column + for (let y = 0; y < 16; y++) { + const row = glyph.rows[y]; + const dst = y < 8 ? top : bottom; + const dy = y & 7; + for (let x = 0; x < 8; x++) { + const bit = (row >> (shiftBase + 7 - x)) & 1; + if (bit) dst[dy * 8 + x] = ink; + } + } + } + return [top, bottom]; +} diff --git a/saga/compiler/cli.ts b/saga/compiler/cli.ts new file mode 100644 index 0000000..d74ccb5 --- /dev/null +++ b/saga/compiler/cli.ts @@ -0,0 +1,29 @@ +#!/usr/bin/env bun +// saga/compiler/cli.ts — `bun saga/compiler/cli.ts build film.tsx --out out.gba` + +import { compileFilm } from "./index.ts"; +import { emitGenData } from "./emit.ts"; +import { buildRom } from "./rom.ts"; + +const args = process.argv.slice(2); +if (args[0] !== "build" || !args[1]) { + console.error("usage: saga build [--out dist/film.gba] [--title TITLE]"); + process.exit(1); +} +const entry = args[1]; +const out = args.includes("--out") ? args[args.indexOf("--out") + 1] : new URL("../dist/film.gba", import.meta.url).pathname; +const title = args.includes("--title") ? args[args.indexOf("--title") + 1] : "SAGA"; + +const film = await compileFilm(entry); +const rom = await buildRom(emitGenData(film), out, title); +await Bun.write(out + ".debug.json", JSON.stringify(film.debug, null, 2)); +console.log( + `saga: ${rom.gba} (${rom.size} bytes), ${film.scenes.length} scenes, ` + + `${film.debug.texts.length} texts, ${film.nHalfcells} glyph halfcells`, +); +for (const s of film.scenes) { + console.log( + ` scene ${s.id}: main ${s.nMain}t${s.wide ? " wide" : ""}, shared ${s.nShared}t, ` + + `obj ${s.objTiles.length / 32}t, cue ${s.cue.length}B`, + ); +} diff --git a/saga/compiler/emit.ts b/saga/compiler/emit.ts new file mode 100644 index 0000000..d6cd76e --- /dev/null +++ b/saga/compiler/emit.ts @@ -0,0 +1,121 @@ +// saga/compiler/emit.ts — residualize the CompiledFilm into runtime/gen_data.c. +// Everything is plain C arrays in ROM; no container parsing at runtime. + +import type { CompiledFilm, CompiledScene } from "./index.ts"; + +function u8arr(name: string, data: Uint8Array): string { + const lines: string[] = [`static const u8 ${name}[] = {`]; + for (let i = 0; i < data.length; i += 24) { + lines.push(" " + Array.from(data.subarray(i, i + 24)).join(",") + ","); + } + lines.push("};"); + return lines.join("\n"); +} + +function u16arr(name: string, data: Uint16Array | number[]): string { + const arr = Array.from(data); + const lines: string[] = [`static const u16 ${name}[] = {`]; + for (let i = 0; i < arr.length; i += 16) { + lines.push(" " + arr.slice(i, i + 16).map((v) => "0x" + v.toString(16)).join(",") + ","); + } + lines.push("};"); + return lines.join("\n"); +} + +function u32arr(name: string, data: number[]): string { + const lines: string[] = [`static const u32 ${name}[] = {`]; + for (let i = 0; i < data.length; i += 12) { + lines.push(" " + data.slice(i, i + 12).map((v) => String(v >>> 0)).join(",") + ","); + } + lines.push("};"); + return lines.join("\n"); +} + +export function emitGenData(film: CompiledFilm): string { + const parts: string[] = [ + "/* gen_data.c — GENERATED by saga/compiler. Do not edit. */", + '#include "saga.h"', + "", + ]; + + film.scenes.forEach((s: CompiledScene, i: number) => { + parts.push(u16arr(`s${i}_pal_bg`, s.palBg)); + parts.push(u16arr(`s${i}_pal_obj`, s.palObj)); + parts.push(u8arr(`s${i}_tiles_main`, s.tilesMain)); + if (s.nShared) parts.push(u8arr(`s${i}_tiles_shared`, s.tilesShared)); + parts.push(u16arr(`s${i}_map_main`, s.mapMain)); + if (s.mapFar) parts.push(u16arr(`s${i}_map_far`, s.mapFar)); + if (s.mapSky) parts.push(u16arr(`s${i}_map_sky`, s.mapSky)); + if (s.gradient) parts.push(u16arr(`s${i}_grad`, s.gradient)); + if (s.objTiles.length) parts.push(u8arr(`s${i}_obj`, s.objTiles)); + if (s.protos.length) { + const rows = s.protos + .map((p) => ` {${p.tileBase},${p.w},${p.h},${p.frames},${p.palbank},${p.fps},${p.walkFpd}},`) + .join("\n"); + parts.push(`static const SagaProto s${i}_protos[] = {\n${rows}\n};`); + } + parts.push(u8arr(`s${i}_cue`, s.cue)); + parts.push(u16arr(`s${i}_cue_offs`, s.cueOffs)); + if (s.world) { + const w = s.world; + parts.push(u8arr(`s${i}_solid`, w.solid)); + if (w.npcs.length) { + const rows = w.npcs + .map((n) => ` {${n.cx},${n.cy},${n.dir},${n.slot},${n.proto},${n.cue},${n.solid}},`) + .join("\n"); + parts.push(`static const SagaNpc s${i}_npcs[] = {\n${rows}\n};`); + } + if (w.trigs.length) { + const rows = w.trigs + .map((t) => ` {${t.cx},${t.cy},${t.w},${t.h},${t.kind},${t.value},${t.cue}},`) + .join("\n"); + parts.push(`static const SagaTrig s${i}_trigs[] = {\n${rows}\n};`); + } + parts.push( + `static const SagaWorld s${i}_world = { ${w.cols}, ${w.rows}, s${i}_solid, ` + + `${w.startCx}, ${w.startCy}, ${w.startDir}, ${w.playerSlot}, ${w.playerProto}, ` + + `${w.npcs.length ? `s${i}_npcs` : "0"}, ${w.npcs.length}, ` + + `${w.trigs.length ? `s${i}_trigs` : "0"}, ${w.trigs.length} };`, + ); + } + parts.push(""); + }); + + parts.push(u32arr("film_text_offs", film.textOffs)); + parts.push(u8arr("film_text_blob", film.textBlob)); + parts.push(u8arr("film_glyphs", film.glyphs)); + parts.push(u8arr("film_ui_bg", film.uiBg)); + parts.push(u8arr("film_ui_obj", film.uiObj)); + parts.push(""); + + const rows = film.scenes.map((s, i) => { + const f = (b: boolean, t: string) => (b ? t : "0"); + return [ + " {", + `s${i}_pal_bg, s${i}_pal_obj,`, + `s${i}_tiles_main, ${s.nMain},`, + `${s.nShared ? `s${i}_tiles_shared` : "0"}, ${s.nShared},`, + `s${i}_map_main,`, + `${f(!!s.mapFar, `s${i}_map_far`)}, ${f(!!s.mapSky, `s${i}_map_sky`)},`, + `${s.mapSz},`, + `${s.farFacQ8}, ${s.skyFacQ8}, ${s.farVxQ8}, ${s.skyVxQ8},`, + `${f(!!s.gradient, `s${i}_grad`)},`, + `${s.objTiles.length ? `s${i}_obj` : "0"}, ${s.objTiles.length},`, + `${s.protos.length ? `s${i}_protos` : "0"}, ${s.protos.length},`, + `s${i}_cue, ${s.cue.length},`, + `s${i}_cue_offs, ${s.cueOffs.length},`, + `${s.kind}, ${f(!!s.world, `&s${i}_world`)},`, + `${s.cam0}, ${s.camMin}, ${s.camMax},`, + `${s.rasterMode}, ${s.rasterAmp}, ${s.letterbox0}, 0x${s.backdrop.toString(16)}`, + "},", + ].join(" "); + }); + parts.push(`static const SagaScene film_scenes[] = {\n${rows.join("\n")}\n};`); + parts.push(""); + parts.push( + `const SagaFilm film = { film_scenes, ${film.scenes.length}, film_text_offs, film_text_blob, ` + + `${film.textOffs.length}, film_glyphs, ${film.nHalfcells}, film_ui_bg, film_ui_obj, ${film.uiObj.length} };`, + ); + parts.push(""); + return parts.join("\n"); +} diff --git a/saga/compiler/evaluate.ts b/saga/compiler/evaluate.ts new file mode 100644 index 0000000..6a51468 --- /dev/null +++ b/saga/compiler/evaluate.ts @@ -0,0 +1,90 @@ +// saga/compiler/evaluate.ts — execute the declaration zone, capture cue ASTs. +// Same bridge as aot: every `cue(function*(){...})` argument is recorded and +// replaced by its id, then the rewritten module is transpiled and imported so +// the DSL builders fill the shared registry. Temp module names carry a +// per-process counter because Bun caches modules by path. + +import ts from "typescript"; +import { pathToFileURL } from "node:url"; + +const DSL_INDEX = new URL("../dsl/index.ts", import.meta.url).pathname; + +let evalCounter = 0; + +export interface CueSite { + id: number; + body: ts.FunctionExpression | ts.ArrowFunction; + file: ts.SourceFile; +} + +export interface EvalResult { + registry: import("../dsl/index.ts").Registry; + cues: CueSite[]; +} + +function findCueCalls(sf: ts.SourceFile): ts.CallExpression[] { + const out: ts.CallExpression[] = []; + const visit = (n: ts.Node): void => { + if ( + ts.isCallExpression(n) && + ts.isIdentifier(n.expression) && + n.expression.text === "cue" && + n.arguments.length === 1 + ) { + out.push(n); + return; + } + ts.forEachChild(n, visit); + }; + visit(sf); + out.sort((a, b) => a.getStart() - b.getStart()); + return out; +} + +export async function evaluateFilm(entryPath: string): Promise { + const source = await Bun.file(entryPath).text(); + const sf = ts.createSourceFile(entryPath, source, ts.ScriptTarget.ES2020, true, ts.ScriptKind.TS); + + const calls = findCueCalls(sf); + const cues: CueSite[] = []; + const edits: { start: number; end: number; text: string }[] = []; + calls.forEach((call, id) => { + const arg = call.arguments[0]; + if (!ts.isFunctionExpression(arg) && !ts.isArrowFunction(arg)) { + throw new Error(`cue() argument must be a function expression (cue #${id})`); + } + cues.push({ id, body: arg, file: sf }); + edits.push({ start: arg.getStart(sf), end: arg.getEnd(), text: String(id) }); + }); + edits.sort((a, b) => b.start - a.start); + let rewritten = source; + for (const e of edits) rewritten = rewritten.slice(0, e.start) + e.text + rewritten.slice(e.end); + rewritten = rewritten.replace(/(["'])@pocketjs\/saga\1/g, JSON.stringify(DSL_INDEX)); + + const js = ts.transpileModule(rewritten, { + fileName: entryPath, + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2020, + esModuleInterop: true, + }, + }).outputText; + + const dsl = (await import(DSL_INDEX)) as typeof import("../dsl/index.ts"); + dsl.__resetRegistry(); + + const tmp = entryPath + `.__saga.${process.pid}.${evalCounter++}.mjs`; + await Bun.write(tmp, js); + try { + await import(pathToFileURL(tmp).href); + } finally { + await Bun.file(tmp) + .exists() + .then((e) => (e ? Bun.$`rm -f ${tmp}`.quiet() : null)) + .catch(() => {}); + } + + const registry = dsl.__getRegistry(); + if (!registry.film) throw new Error("no defineFilm() found in " + entryPath); + return { registry, cues }; +} diff --git a/saga/compiler/index.ts b/saga/compiler/index.ts new file mode 100644 index 0000000..038259c --- /dev/null +++ b/saga/compiler/index.ts @@ -0,0 +1,507 @@ +// saga/compiler/index.ts — compileFilm(entry): evaluate the declaration zone, +// bake assets, residualize cues, and assemble the CompiledFilm model that +// emit.ts turns into gen_data.c. + +import { dirname, resolve } from "node:path"; +import { evaluateFilm } from "./evaluate.ts"; +import { residualizeCue, type CueCtx } from "./residualize.ts"; +import { TextBank } from "./text.ts"; +import { + loadPng, quantize, tileLayer, buildMap, tileObjSheet, gradientTable, + uiPalette, uiBgTiles, uiObjTiles, type Quantized, +} from "./assets.ts"; +import { + FARSKY_BASE, FARSKY_MAX, MAIN_TILE_MAX, GLYPH_SLOTS, + PALBANK_SKY, PALBANK_FAR, PALBANK_MAIN, PALBANK_UI, PALBANK_OBJ_UI, + MAX_SPRITES, MAX_SCENES, hex555, UI_INK, UI_BOX, + SCENE_CINE, SCENE_WORLD, CELL_PX, WORLD_COLS_MAX, WORLD_ROWS_MAX, + MAX_NPCS, MAX_TRIGS, MAX_CUES, TRIG_EXIT, TRIG_EXAMINE, TRIG_AUTO, + DIR_DOWN, DIR_UP, DIR_LEFT, DIR_RIGHT, +} from "../spec/saga.ts"; +import type { ActorDecl, CellRef, CueRef, LayerDecl, RectRef, SceneDecl, WorldDecl } from "../dsl/index.ts"; + +export interface CompiledProto { + tileBase: number; + w: number; + h: number; + frames: number; + palbank: number; + fps: number; + walkFpd: number; +} + +export interface CompiledNpc { + cx: number; + cy: number; + dir: number; + slot: number; + proto: number; + cue: number; // 0xff none + solid: number; +} + +export interface CompiledTrig { + cx: number; + cy: number; + w: number; + h: number; + kind: number; + value: number; + cue: number; // 0xff none +} + +export interface CompiledWorld { + cols: number; + rows: number; + solid: Uint8Array; + startCx: number; + startCy: number; + startDir: number; + playerSlot: number; + playerProto: number; + npcs: CompiledNpc[]; + trigs: CompiledTrig[]; +} + +export interface CompiledScene { + id: string; + palBg: Uint16Array; // 256 + palObj: Uint16Array; // 256 + tilesMain: Uint8Array; + nMain: number; + tilesShared: Uint8Array; + nShared: number; + mapMain: Uint16Array; + mapFar: Uint16Array | null; + mapSky: Uint16Array | null; + mapSz: 0 | 1 | 2; + kind: number; + world: CompiledWorld | null; + cueOffs: number[]; + farFacQ8: number; + skyFacQ8: number; + farVxQ8: number; + skyVxQ8: number; + gradient: Uint16Array | null; + objTiles: Uint8Array; + protos: CompiledProto[]; + cue: Uint8Array; + cam0: number; + camMin: number; + camMax: number; + rasterMode: number; + rasterAmp: number; + letterbox0: number; + backdrop: number; +} + +export interface CompiledFilm { + title: string; + scenes: CompiledScene[]; + textOffs: number[]; + textBlob: Uint8Array; + glyphs: Uint8Array; + nHalfcells: number; + uiBg: Uint8Array; + uiObj: Uint8Array; + debug: { + sceneIds: Record; + texts: string[]; + vars: Record; + flags: Record; + }; +} + +const RASTER_OFF = 0, RASTER_GRADIENT = 1, RASTER_WAVE_MAIN = 2, RASTER_WAVE_FAR = 3; + +export async function compileFilm(entryPath: string): Promise { + const entry = resolve(entryPath); + const base = dirname(entry); + const { registry, cues } = await evaluateFilm(entry); + const film = registry.film!; + if (film.scenes.length === 0) throw new Error("film has no scenes"); + if (film.scenes.length > MAX_SCENES) throw new Error(`too many scenes (max ${MAX_SCENES})`); + + const sceneIndex = new Map(); + film.scenes.forEach((s, i) => { + if (sceneIndex.has(s.id)) throw new Error(`duplicate scene id ${s.id}`); + sceneIndex.set(s.id, i); + }); + + const texts = new TextBank(); + const vars = new Map(); + const flags = new Map(); + + const scenes: CompiledScene[] = []; + for (const decl of film.scenes) { + scenes.push(await compileScene(decl, base, { texts, vars, flags, sceneIndex, cues })); + } + + const { offs, blob } = texts.buildBlob(); + const glyphs = texts.bakeGlyphStore(UI_INK, UI_BOX); + + return { + title: film.title, + scenes, + textOffs: offs, + textBlob: blob, + glyphs, + nHalfcells: glyphs.length / 64, + uiBg: uiBgTiles(), + uiObj: uiObjTiles(), + debug: { + sceneIds: Object.fromEntries(sceneIndex), + texts: texts.entries.map((e) => e.raw), + vars: Object.fromEntries(vars), + flags: Object.fromEntries(flags), + }, + }; +} + +interface SceneEnv { + texts: TextBank; + vars: Map; + flags: Map; + sceneIndex: Map; + cues: import("./evaluate.ts").CueSite[]; +} + +async function loadLayer(base: string, layer: LayerDecl): Promise { + const img = await loadPng(resolve(base, layer.png)); + return quantize(img, 15); +} + +const DIR_NUM: Record = { down: DIR_DOWN, up: DIR_UP, left: DIR_LEFT, right: DIR_RIGHT }; + +function compileWorld( + sceneId: string, + wd: WorldDecl, + imgW: number, + imgH: number, + actors: CueCtx["actors"], + protos: CompiledProto[], + addCue: (name: string, ref: CueRef | undefined) => number, +): CompiledWorld { + const err = (msg: string): never => { + throw new Error(`[${sceneId}] world: ${msg}`); + }; + const rows = wd.grid.length; + const cols = Math.max(...wd.grid.map((r) => r.length)); + const expCols = Math.ceil(imgW / CELL_PX); + const expRows = Math.ceil(imgH / CELL_PX); + if (cols !== expCols || rows !== expRows) + err(`grid is ${cols}x${rows} cells but the main image is ${expCols}x${expRows} (${imgW}x${imgH}px)`); + if (cols > WORLD_COLS_MAX || rows > WORLD_ROWS_MAX) err(`grid too large (max ${WORLD_COLS_MAX}x${WORLD_ROWS_MAX})`); + + const solid = new Uint8Array(cols * rows); + const named = new Map(); + for (let r = 0; r < rows; r++) { + for (let c = 0; c < cols; c++) { + const ch = wd.grid[r][c] ?? "#"; + if (ch === "#") solid[r * cols + c] = 1; + else if (ch !== "." && ch !== " ") { + if (!named.has(ch)) named.set(ch, []); + named.get(ch)!.push([c, r]); + } + } + } + const cellOf = (at: CellRef, what: string): [number, number] => { + if (typeof at !== "string") return at; + const cells = named.get(at); + if (!cells) return err(`${what}: no cell '${at}' in grid`); + return cells[0]; + }; + const rectOf = (at: RectRef, what: string): [number, number, number, number] => { + if (typeof at !== "string") return at; + const cells = named.get(at); + if (!cells) return err(`${what}: no cell '${at}' in grid`); + const xs = cells.map((c) => c[0]); + const ys = cells.map((c) => c[1]); + const x0 = Math.min(...xs), y0 = Math.min(...ys); + return [x0, y0, Math.max(...xs) - x0 + 1, Math.max(...ys) - y0 + 1]; + }; + const actorOf = (name: string, what: string) => { + const a = actors.get(name); + if (!a) return err(`${what}: unknown actor "${name}"`); + return a; + }; + + const pa = actorOf(wd.player.actor, "player"); + if (!protos[pa.proto].walkFpd) err(`player actor "${wd.player.actor}" needs walkFpd`); + const [scx, scy] = cellOf(wd.player.at, "player.at"); + + const npcs: CompiledNpc[] = []; + for (const [name, n] of Object.entries(wd.npcs ?? {})) { + if (npcs.length >= MAX_NPCS) err(`too many npcs (max ${MAX_NPCS})`); + const a = actorOf(n.actor, `npc ${name}`); + const [cx, cy] = cellOf(n.at, `npc ${name}.at`); + npcs.push({ + cx, + cy, + dir: DIR_NUM[n.dir ?? "down"], + slot: a.slot, + proto: a.proto, + cue: addCue(`${sceneId}.${name}`, n.talk), + solid: n.solid === false ? 0 : 1, + }); + } + + const trigs: CompiledTrig[] = []; + const addTrig = (kind: number, name: string, at: RectRef, value: number, ref?: CueRef): void => { + if (trigs.length >= MAX_TRIGS) err(`too many triggers (max ${MAX_TRIGS})`); + const [cx, cy, w, h] = rectOf(at, name); + trigs.push({ cx, cy, w, h, kind, value, cue: addCue(`${sceneId}.${name}`, ref) }); + }; + for (const [name, e] of Object.entries(wd.exits ?? {})) addTrig(TRIG_EXIT, name, e.at, e.value); + for (const [name, s] of Object.entries(wd.spots ?? {})) addTrig(TRIG_EXAMINE, name, s.at, 0, s.run); + for (const [name, s] of Object.entries(wd.autos ?? {})) addTrig(TRIG_AUTO, name, s.at, 0, s.run); + + return { + cols, + rows, + solid, + startCx: scx, + startCy: scy, + startDir: DIR_NUM[wd.player.dir ?? "down"], + playerSlot: pa.slot, + playerProto: pa.proto, + npcs, + trigs, + }; +} + +async function compileScene(decl: SceneDecl, base: string, env: SceneEnv): Promise { + const palBg = new Uint16Array(256); + const palObj = new Uint16Array(256); + + // UI palettes (BG bank 15 + OBJ bank 15) + uiPalette().forEach((c, i) => { + palBg[PALBANK_UI * 16 + i] = c; + palObj[PALBANK_OBJ_UI * 16 + i] = c; + }); + + const backdrop = hex555(decl.backdrop ?? "#000000"); + palBg[0] = backdrop; + + // --- main layer -> charblock 0 ------------------------------------------------ + const isWorld = !!decl.world; + let tilesMain = new Uint8Array(32); // tile 0 blank + let nMain = 1; + let mapMain = new Uint16Array(1024); + let mapSz: 0 | 1 | 2 = 0; + let imgW = 240; + let imgH = 160; + if (isWorld && !decl.main) throw new Error(`[${decl.id}] world scenes need a main image`); + if (decl.main) { + const q = await loadLayer(base, decl.main); + imgW = q.w; + imgH = q.h; + mapSz = isWorld ? 2 : q.w > 240 ? 1 : 0; + if (q.w > 512) throw new Error(`[${decl.id}] main image too wide (max 512): ${q.w}`); + if (q.h > (mapSz === 2 ? 512 : 256)) throw new Error(`[${decl.id}] main image too tall: ${q.h}`); + const tl = tileLayer(q); + if (1 + tl.tiles.length > MAIN_TILE_MAX) throw new Error(`[${decl.id}] main tiles ${tl.tiles.length} > budget`); + tilesMain = new Uint8Array((1 + tl.tiles.length) * 32); + tl.tiles.forEach((t, i) => tilesMain.set(t, (1 + i) * 32)); + nMain = 1 + tl.tiles.length; + mapMain = buildMap(tl, mapSz, 1, PALBANK_MAIN); + q.pal555.forEach((c, i) => { + if (i > 0) palBg[PALBANK_MAIN * 16 + i] = c; + }); + } + if (isWorld && (decl.far || decl.sky?.kind === "image")) + throw new Error(`[${decl.id}] world scenes cannot have far/sky layers (their screenblocks hold the map)`); + + // --- far + sky -> shared charblock ----------------------------------------------- + const sharedTiles: Uint8Array[] = []; + let mapFar: Uint16Array | null = null; + let mapSky: Uint16Array | null = null; + let gradient: Uint16Array | null = null; + let farFacQ8 = Math.round((decl.far?.scroll ?? 0.4) * 256); + let skyFacQ8 = 0; + const farVxQ8 = Math.round((decl.far?.vx ?? 0) * 256); + let skyVxQ8 = 0; + + if (decl.far) { + const q = await loadLayer(base, decl.far); + const tl = tileLayer(q); + const tileBase = FARSKY_BASE + sharedTiles.length; + sharedTiles.push(...tl.tiles); + mapFar = buildMap(tl, 0, tileBase, PALBANK_FAR, (decl.far.y ?? 0) >> 3); + q.pal555.forEach((c, i) => { + if (i > 0) palBg[PALBANK_FAR * 16 + i] = c; + }); + } + if (decl.sky) { + if (decl.sky.kind === "gradient") { + gradient = gradientTable(decl.sky.stops); + } else { + const q = await loadPng(resolve(base, decl.sky.png)).then((img) => quantize(img, 15)); + const tl = tileLayer(q); + const tileBase = FARSKY_BASE + sharedTiles.length; + sharedTiles.push(...tl.tiles); + mapSky = buildMap(tl, 0, tileBase, PALBANK_SKY, (decl.sky.y ?? 0) >> 3); + q.pal555.forEach((c, i) => { + if (i > 0) palBg[PALBANK_SKY * 16 + i] = c; + }); + skyFacQ8 = Math.round((decl.sky.scroll ?? 0.15) * 256); + skyVxQ8 = Math.round((decl.sky.vx ?? 0) * 256); + } + } + if (sharedTiles.length > FARSKY_MAX) throw new Error(`[${decl.id}] far+sky tiles ${sharedTiles.length} > ${FARSKY_MAX}`); + const tilesShared = new Uint8Array(sharedTiles.length * 32); + sharedTiles.forEach((t, i) => tilesShared.set(t, i * 32)); + + // --- actors -> protos + OBJ sheet ---------------------------------------------------- + const actorEntries = Object.entries(decl.actors ?? {}); + if (actorEntries.length > MAX_SPRITES) throw new Error(`[${decl.id}] too many actors (max ${MAX_SPRITES})`); + const protos: CompiledProto[] = []; + const protoByPng = new Map(); + const objParts: Uint8Array[] = []; + let objTileCursor = 0; + const actors: CueCtx["actors"] = new Map(); + let nextObjBank = 0; + + for (const [name, a] of actorEntries) { + const key = `${a.png}|${a.w}x${a.h}x${a.frames ?? 1}`; + let protoIdx = protoByPng.get(key); + if (protoIdx === undefined) { + const img = await loadPng(resolve(base, a.png)); + const frames = a.frames ?? 1; + if (img.width < a.w * frames || img.height < a.h) { + throw new Error(`[${decl.id}] sprite ${a.png}: ${img.width}x${img.height} < ${a.w * frames}x${a.h}`); + } + const q = quantize(img, 15); + if (nextObjBank >= PALBANK_OBJ_UI) throw new Error(`[${decl.id}] too many OBJ palettes`); + const bank = nextObjBank++; + q.pal555.forEach((c, i) => { + if (i > 0) palObj[bank * 16 + i] = c; + }); + const sheet = tileObjSheet(q, a.w, a.h, frames); + if (a.walkFpd && frames < 3 * a.walkFpd) { + throw new Error( + `[${decl.id}] walker ${a.png}: needs ${3 * a.walkFpd} frames (3 rows x walkFpd), has ${frames}`, + ); + } + protoIdx = protos.length; + protos.push({ + tileBase: objTileCursor, + w: a.w, + h: a.h, + frames, + palbank: bank, + fps: a.fps ?? 10, + walkFpd: a.walkFpd ?? 0, + }); + objTileCursor += sheet.length / 32; + objParts.push(sheet); + protoByPng.set(key, protoIdx); + } + actors.set(name, { slot: actors.size, proto: protoIdx, decl: a }); + } + if (objTileCursor > 1000) throw new Error(`[${decl.id}] OBJ tiles ${objTileCursor} > 1000 budget`); + const objTiles = new Uint8Array(objParts.reduce((n, p) => n + p.length, 0)); + { + let o = 0; + for (const p of objParts) { + objTiles.set(p, o); + o += p.length; + } + } + + // --- world (top-down grid) -------------------------------------------------------- + // The cue table: cue 0 = play; NPC talk / spot / auto cues follow. + const cueRefs: { name: string; ref: CueRef }[] = []; + const addCue = (name: string, ref: CueRef | undefined): number => { + if (!ref) return 0xff; + if (typeof (ref as { __cue?: number }).__cue !== "number") + throw new Error(`[${decl.id}] ${name}: expected cue(function* () { ... })`); + if (cueRefs.length >= MAX_CUES) throw new Error(`[${decl.id}] too many cues (max ${MAX_CUES})`); + cueRefs.push({ name, ref }); + return cueRefs.length - 1; + }; + if (!decl.play || typeof (decl.play as { __cue?: number }).__cue !== "number") { + throw new Error(`[${decl.id}] scene has no play: cue(...)`); + } + addCue(decl.id, decl.play); + + let world: CompiledWorld | null = null; + if (decl.world) { + world = compileWorld(decl.id, decl.world, imgW, imgH, actors, protos, addCue); + } + + const cueOffs: number[] = []; + const cueParts: Uint8Array[] = []; + let cueLen = 0; + for (const { name, ref } of cueRefs) { + const site = env.cues.find((c) => c.id === (ref as { __cue: number }).__cue); + if (!site) throw new Error(`[${decl.id}] cue site not found for ${name}`); + const bytes = residualizeCue( + site.body, + { + texts: env.texts, + vars: env.vars, + flags: env.flags, + sceneIndex: env.sceneIndex, + actors, + cueName: name, + }, + cueLen, // jump targets are blob-absolute + ); + cueOffs.push(cueLen); + cueParts.push(bytes); + cueLen += bytes.length; + } + const cue = new Uint8Array(cueLen); + { + let o = 0; + for (const p of cueParts) { + cue.set(p, o); + o += p.length; + } + } + + // --- camera + raster defaults ---------------------------------------------------------- + const camMin = decl.camera?.min ?? 0; + const camMax = decl.camera?.max ?? Math.max(0, imgW - 240); + const cam0 = decl.camera?.start ?? camMin; + let rasterMode = RASTER_OFF; + let rasterAmp = 0; + if (gradient) rasterMode = RASTER_GRADIENT; + if (decl.wave) { + rasterMode = decl.wave.layer === "far" ? RASTER_WAVE_FAR : RASTER_WAVE_MAIN; + rasterAmp = decl.wave.amp; + } + + return { + id: decl.id, + palBg, + palObj, + tilesMain, + nMain, + tilesShared, + nShared: sharedTiles.length, + mapMain, + mapFar, + mapSky, + mapSz, + kind: isWorld ? SCENE_WORLD : SCENE_CINE, + world, + cueOffs, + farFacQ8, + skyFacQ8, + farVxQ8, + skyVxQ8, + gradient, + objTiles, + protos, + cue, + cam0, + camMin, + camMax, + rasterMode, + rasterAmp, + letterbox0: decl.letterbox ?? 0, + backdrop, + }; +} diff --git a/saga/compiler/png.ts b/saga/compiler/png.ts new file mode 100644 index 0000000..4ed71f1 --- /dev/null +++ b/saga/compiler/png.ts @@ -0,0 +1,155 @@ +// saga/compiler/png.ts — minimal PNG codec (build-time only), self-contained so +// @pocketjs/saga stays independent. Decoder adapted from compiler/pak.ts, +// encoder from scripts/psp-all.ts. + +import { inflateSync, deflateSync } from "node:zlib"; + +export interface DecodedImage { + width: number; + height: number; + /** RGBA, 4 bytes/px, row-major. */ + rgba: Uint8Array; +} + +/** Decode an 8-bit RGB/RGBA/grayscale non-interlaced PNG to RGBA. */ +export function decodePng(bytes: Uint8Array): DecodedImage { + const SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + for (let i = 0; i < 8; i++) { + if (bytes[i] !== SIG[i]) throw new Error("png: bad signature"); + } + const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let o = 8; + let width = 0; + let height = 0; + let bitDepth = 0; + let colorType = 0; + const idat: Uint8Array[] = []; + while (o + 8 <= bytes.length) { + const len = dv.getUint32(o, false); + const type = String.fromCharCode(bytes[o + 4], bytes[o + 5], bytes[o + 6], bytes[o + 7]); + const body = bytes.subarray(o + 8, o + 8 + len); + if (type === "IHDR") { + width = dv.getUint32(o + 8, false); + height = dv.getUint32(o + 12, false); + bitDepth = bytes[o + 16]; + colorType = bytes[o + 17]; + if (bytes[o + 20] !== 0) throw new Error("png: interlaced PNGs unsupported"); + } else if (type === "IDAT") { + idat.push(body); + } else if (type === "IEND") { + break; + } + o += 12 + len; + } + if (bitDepth !== 8) throw new Error(`png: only 8-bit supported (got ${bitDepth})`); + const channels = { 0: 1, 2: 3, 4: 2, 6: 4 }[colorType]; + if (!channels) throw new Error(`png: unsupported color type ${colorType} (palette PNGs: re-export as RGBA)`); + + const zdata = new Uint8Array(idat.reduce((n, c) => n + c.length, 0)); + let zo = 0; + for (const c of idat) { + zdata.set(c, zo); + zo += c.length; + } + const raw = new Uint8Array(inflateSync(zdata)); + + const stride = width * channels; + const rgba = new Uint8Array(width * height * 4); + const prev = new Uint8Array(stride); + const line = new Uint8Array(stride); + let ro = 0; + for (let y = 0; y < height; y++) { + const filter = raw[ro++]; + for (let x = 0; x < stride; x++) { + const cur = raw[ro + x]; + const a = x >= channels ? line[x - channels] : 0; + const b = prev[x]; + const c = x >= channels ? prev[x - channels] : 0; + let v: number; + switch (filter) { + case 0: v = cur; break; + case 1: v = cur + a; break; + case 2: v = cur + b; break; + case 3: v = cur + ((a + b) >> 1); break; + case 4: { + const p = a + b - c; + const pa = Math.abs(p - a); + const pb = Math.abs(p - b); + const pc = Math.abs(p - c); + v = cur + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c); + break; + } + default: throw new Error(`png: bad filter ${filter}`); + } + line[x] = v & 0xff; + } + ro += stride; + for (let x = 0; x < width; x++) { + const i = (y * width + x) * 4; + const s = x * channels; + switch (colorType) { + case 0: rgba[i] = rgba[i + 1] = rgba[i + 2] = line[s]; rgba[i + 3] = 255; break; + case 2: rgba[i] = line[s]; rgba[i + 1] = line[s + 1]; rgba[i + 2] = line[s + 2]; rgba[i + 3] = 255; break; + case 4: rgba[i] = rgba[i + 1] = rgba[i + 2] = line[s]; rgba[i + 3] = line[s + 1]; break; + case 6: rgba[i] = line[s]; rgba[i + 1] = line[s + 1]; rgba[i + 2] = line[s + 2]; rgba[i + 3] = line[s + 3]; break; + } + } + prev.set(line); + } + return { width, height, rgba }; +} + +// --- encoder ------------------------------------------------------------------- + +const CRC_TABLE = (() => { + const t = new Uint32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + t[n] = c >>> 0; + } + return t; +})(); + +function crc32(buf: Uint8Array): number { + let c = 0xffffffff; + for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8); + return (c ^ 0xffffffff) >>> 0; +} + +function chunk(type: string, data: Uint8Array): Uint8Array { + const out = new Uint8Array(12 + data.length); + const dv = new DataView(out.buffer); + dv.setUint32(0, data.length, false); + for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i); + out.set(data, 8); + const body = out.subarray(4, 8 + data.length); + dv.setUint32(8 + data.length, crc32(body), false); + return out; +} + +export function encodePng(rgba: Uint8Array, w: number, h: number): Uint8Array { + const stride = w * 4; + const raw = new Uint8Array((stride + 1) * h); + for (let y = 0; y < h; y++) { + raw[y * (stride + 1)] = 0; + raw.set(rgba.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1); + } + const ihdr = new Uint8Array(13); + const dv = new DataView(ihdr.buffer); + dv.setUint32(0, w, false); + dv.setUint32(4, h, false); + ihdr[8] = 8; + ihdr[9] = 6; + const sig = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + const idat = new Uint8Array(deflateSync(raw)); + const parts = [sig, chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", new Uint8Array(0))]; + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let o = 0; + for (const p of parts) { + out.set(p, o); + o += p.length; + } + return out; +} diff --git a/saga/compiler/residualize.ts b/saga/compiler/residualize.ts new file mode 100644 index 0000000..90338c2 --- /dev/null +++ b/saga/compiler/residualize.ts @@ -0,0 +1,496 @@ +// saga/compiler/residualize.ts — lower cue generator ASTs to cue-VM bytecode. +// +// Recognized statement forms (anything else is a compile error, on purpose — +// residual code is a DSL, not JavaScript): +// yield op(...); op from the vocabulary table below +// const x = yield choice([...]); result var (also hasFlag/rnd/var*) +// if (cond) {...} else {...} cond: x === n | x < n | yield hasFlag() +// | yield varXx() | !cond | (cond) +// while (cond) {...} break; return; +// setVar/addVar/setFlag/clrFlag via yield like any op. + +import ts from "typescript"; +import { + OP, TW, ByteWriter, CMP_EQ, CMP_NE, CMP_LT, CMP_GT, CMP_LE, CMP_GE, + FADE_IN_BLACK, FADE_OUT_BLACK, FADE_IN_WHITE, FADE_OUT_WHITE, + RASTER_OFF, RASTER_GRADIENT, RASTER_WAVE_MAIN, RASTER_WAVE_FAR, + CAP_CHIP, CAP_SUB, CAP_CARD, + EASE_LINEAR, EASE_IN, EASE_OUT, EASE_INOUT, + SFX_BLIP, SFX_CONFIRM, SFX_WHOOSH, SFX_STAR, + SPR_HFLIP, SPR_SCREEN, SPR_BEHIND, SPR_GHOST, + TW_SPRITE_BASE, N_VARS, N_FLAGS, MAX_CHOICES, + DIR_DOWN, DIR_UP, DIR_LEFT, DIR_RIGHT, BRICK_ROWS_MAX, +} from "../spec/saga.ts"; +import type { TextBank } from "./text.ts"; +import type { ActorDecl } from "../dsl/index.ts"; + +export interface CueCtx { + texts: TextBank; + vars: Map; + flags: Map; + sceneIndex: Map; + /** current scene's actors: name -> { slot, proto, decl } */ + actors: Map; + cueName: string; +} + +const EASES: Record = { linear: EASE_LINEAR, in: EASE_IN, out: EASE_OUT, inout: EASE_INOUT }; +const SFXS: Record = { blip: SFX_BLIP, confirm: SFX_CONFIRM, whoosh: SFX_WHOOSH, star: SFX_STAR }; +const CAPS: Record = { chip: CAP_CHIP, sub: CAP_SUB, card: CAP_CARD }; +const DIRS: Record = { down: DIR_DOWN, up: DIR_UP, left: DIR_LEFT, right: DIR_RIGHT }; + +/** `base` = this cue's offset in the scene's concatenated cue blob. All jump + * targets are blob-absolute at runtime, so every emitted/patched target must + * add it (a sub-cue's `if` would otherwise jump into the play cue). */ +export function residualizeCue( + body: ts.FunctionExpression | ts.ArrowFunction, + ctx: CueCtx, + base = 0, +): Uint8Array { + const w = new ByteWriter(); + const endJumps: number[] = []; + const breakStack: number[][] = []; + + const err = (n: ts.Node, msg: string): never => { + const sf = n.getSourceFile(); + const { line } = sf.getLineAndCharacterOfPosition(n.getStart()); + throw new Error(`[${ctx.cueName}] ${msg} (line ${line + 1}): ${n.getText().slice(0, 80)}`); + }; + + const internVar = (name: string): number => { + let id = ctx.vars.get(name); + if (id === undefined) { + id = ctx.vars.size; + if (id >= N_VARS) throw new Error(`too many vars (max ${N_VARS}): ${name}`); + ctx.vars.set(name, id); + } + return id; + }; + const internFlag = (name: string): number => { + let id = ctx.flags.get(name); + if (id === undefined) { + id = ctx.flags.size; + if (id >= N_FLAGS) throw new Error(`too many flags (max ${N_FLAGS}): ${name}`); + ctx.flags.set(name, id); + } + return id; + }; + + const num = (n: ts.Expression): number => { + if (ts.isNumericLiteral(n)) return Number(n.text); + if (ts.isPrefixUnaryExpression(n) && n.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(n.operand)) + return -Number(n.operand.text); + return err(n, "expected a numeric literal"); + }; + const str = (n: ts.Expression): string => { + if (ts.isStringLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n)) return n.text; + return err(n, "expected a string literal"); + }; + const actor = (n: ts.Expression): { slot: number; proto: number; decl: ActorDecl } => { + const name = str(n); + const a = ctx.actors.get(name); + if (!a) return err(n, `unknown actor "${name}" in this scene`); + return a; + }; + + const asCall = (e: ts.Expression): { name: string; args: readonly ts.Expression[] } | null => { + if (ts.isCallExpression(e) && ts.isIdentifier(e.expression)) return { name: e.expression.text, args: e.arguments }; + return null; + }; + + /** value-producing yields usable in conditions / assignments (push result) */ + const emitValueYield = (call: { name: string; args: readonly ts.Expression[] }, n: ts.Node): void => { + switch (call.name) { + case "choice": { + const arr = call.args[0]; + if (!arr || !ts.isArrayLiteralExpression(arr)) return err(n, "choice() takes an array literal"); + const ids = arr.elements.map((e) => ctx.texts.intern(str(e as ts.Expression), { cols: 22, maxLines: 1 })); + if (ids.length < 2 || ids.length > MAX_CHOICES) return err(n, `choice() needs 2..${MAX_CHOICES} options`); + w.u8(OP.CHOICE).u8(ids.length); + for (const id of ids) w.u16(id); + return; + } + case "hasFlag": + w.u8(OP.GET_FLAG).u8(internFlag(str(call.args[0]))); + return; + case "rnd": + w.u8(OP.RND).u8(num(call.args[0])); + return; + case "varEq": + case "varNe": + case "varLt": + case "varGt": + case "varLe": + case "varGe": { + const kind = { varEq: CMP_EQ, varNe: CMP_NE, varLt: CMP_LT, varGt: CMP_GT, varLe: CMP_LE, varGe: CMP_GE }[ + call.name + ]!; + w.u8(OP.GET_VAR).u8(internVar(str(call.args[0]))); + w.u8(OP.PUSH).i16(num(call.args[1])); + w.u8(OP.CMP).u8(kind); + return; + } + case "world": + w.u8(OP.WORLD); + return; + case "breakout": { + const rows = num(call.args[0]); + const lives = num(call.args[1]); + const frames = call.args[2] ? num(call.args[2]) : 3600; + if (rows < 1 || rows > BRICK_ROWS_MAX) return err(n, `breakout rows 1..${BRICK_ROWS_MAX}`); + w.u8(OP.BREAKOUT).u8(rows).u8(lives).u16(frames); + return; + } + default: + return err(n, `${call.name}() does not produce a value here`); + } + }; + + /** condition -> leaves 0/1-ish on stack */ + const emitCond = (e: ts.Expression): void => { + if (ts.isParenthesizedExpression(e)) return emitCond(e.expression); + if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.ExclamationToken) { + emitCond(e.operand); + w.u8(OP.PUSH).i16(0); + w.u8(OP.CMP).u8(CMP_EQ); + return; + } + if (ts.isYieldExpression(e) && e.expression) { + const call = asCall(e.expression); + if (!call) return err(e, "yield in condition must be a call"); + return emitValueYield(call, e); + } + if (ts.isBinaryExpression(e)) { + const KINDS: Partial> = { + [ts.SyntaxKind.EqualsEqualsEqualsToken]: CMP_EQ, + [ts.SyntaxKind.EqualsEqualsToken]: CMP_EQ, + [ts.SyntaxKind.ExclamationEqualsEqualsToken]: CMP_NE, + [ts.SyntaxKind.ExclamationEqualsToken]: CMP_NE, + [ts.SyntaxKind.LessThanToken]: CMP_LT, + [ts.SyntaxKind.GreaterThanToken]: CMP_GT, + [ts.SyntaxKind.LessThanEqualsToken]: CMP_LE, + [ts.SyntaxKind.GreaterThanEqualsToken]: CMP_GE, + }; + const kind = KINDS[e.operatorToken.kind]; + if (kind === undefined) return err(e, "unsupported comparison"); + if (!ts.isIdentifier(e.left)) return err(e, "left side must be a result variable"); + w.u8(OP.GET_VAR).u8(internVar(localName(e.left.text))); + w.u8(OP.PUSH).i16(num(e.right)); + w.u8(OP.CMP).u8(kind); + return; + } + if (ts.isIdentifier(e)) { + w.u8(OP.GET_VAR).u8(internVar(localName(e.text))); + return; + } + return err(e, "unsupported condition"); + }; + + const localName = (name: string): string => `${ctx.cueName}:${name}`; + + /** statement-position yield op table */ + const emitOp = (call: { name: string; args: readonly ts.Expression[] }, n: ts.Node): void => { + const a = call.args; + const easeArg = (i: number, dflt: number): number => { + if (!a[i]) return dflt; + const e = EASES[str(a[i])]; + if (e === undefined) return err(n, `unknown ease ${a[i].getText()}`); + return e; + }; + switch (call.name) { + case "fadeIn": + case "fadeOut": { + const frames = a[0] ? num(a[0]) : 30; + const white = a[1] ? str(a[1]) === "white" : false; + const mode = call.name === "fadeIn" ? (white ? FADE_IN_WHITE : FADE_IN_BLACK) : white ? FADE_OUT_WHITE : FADE_OUT_BLACK; + w.u8(OP.FADE).u8(mode).u16(frames); + return; + } + case "wait": + w.u8(OP.WAIT).u16(num(a[0])); + return; + case "waitA": + w.u8(OP.WAITA); + return; + case "waitTweens": + w.u8(OP.WAIT_TWEENS); + return; + case "caption": { + const style = CAPS[str(a[0])]; + if (style === undefined) return err(n, "caption style must be chip|sub|card"); + const opts = style === CAP_CHIP ? { cols: 24, maxLines: 1 } : {}; + w.u8(OP.CAPTION).u8(style).u16(ctx.texts.intern(str(a[1]), opts)); + return; + } + case "captionClear": { + const s = a[0] ? str(a[0]) : "all"; + w.u8(OP.CAPTION_CLR).u8(s === "all" ? 0xff : (CAPS[s] ?? 0xff)); + return; + } + case "dialog": + w.u8(OP.DIALOG) + .u16(ctx.texts.intern(str(a[0]), { cols: 12, maxLines: 1 })) + .u16(ctx.texts.intern(str(a[1]))); + return; + case "pan": + w.u8(OP.TWEEN).u8(TW.CAM_X).u8(easeArg(2, EASE_INOUT)).i16(num(a[0])).u16(num(a[1])); + return; + case "panY": + w.u8(OP.TWEEN).u8(TW.CAM_Y).u8(easeArg(2, EASE_INOUT)).i16(num(a[0])).u16(num(a[1])); + return; + case "alpha": + w.u8(OP.TWEEN).u8(TW.EVA).u8(EASE_LINEAR).i16(num(a[0])).u16(num(a[2])); + w.u8(OP.TWEEN).u8(TW.EVB).u8(EASE_LINEAR).i16(num(a[1])).u16(num(a[2])); + return; + case "mosaicTo": + w.u8(OP.TWEEN).u8(TW.MOSAIC).u8(EASE_LINEAR).i16(num(a[0])).u16(num(a[1])); + return; + case "shake": + w.u8(OP.TWEEN).u8(TW.SHAKE).u8(EASE_LINEAR).i16(num(a[0])).u16(0); + w.u8(OP.TWEEN).u8(TW.SHAKE).u8(EASE_OUT).i16(0).u16(num(a[1])); + return; + case "autoScroll": { + const t = str(a[0]) === "far" ? TW.FAR_VX : TW.SKY_VX; + w.u8(OP.TWEEN).u8(t).u8(EASE_LINEAR).i16(Math.round(numF(a[1]) * 256)).u16(a[2] ? num(a[2]) : 0); + return; + } + case "zoom": + w.u8(OP.TWEEN).u8(TW.OBJ_SCALE).u8(easeArg(2, EASE_INOUT)).i16(Math.round(numF(a[0]) * 256)).u16(num(a[1])); + return; + case "spinTo": + w.u8(OP.TWEEN).u8(TW.OBJ_ANGLE).u8(easeArg(2, EASE_LINEAR)).i16(Math.round((num(a[0]) / 360) * 256)).u16(num(a[1])); + return; + case "letterbox": + w.u8(OP.LETTERBOX).u8(num(a[0])).u16(a[1] ? num(a[1]) : 20); + return; + case "rasterWave": + w.u8(OP.RASTER).u8(str(a[0]) === "far" ? RASTER_WAVE_FAR : RASTER_WAVE_MAIN).u8(num(a[1])); + return; + case "rasterGradient": + w.u8(OP.RASTER).u8(RASTER_GRADIENT).u8(0); + return; + case "rasterOff": + w.u8(OP.RASTER).u8(RASTER_OFF).u8(0); + return; + case "show": { + const ac = actor(a[0]); + const x = a[1] ? num(a[1]) : (ac.decl.at?.[0] ?? 0); + const y = a[2] ? num(a[2]) : (ac.decl.at?.[1] ?? 0); + let flags = 0; + if (ac.decl.flip) flags |= SPR_HFLIP; + if (ac.decl.screen) flags |= SPR_SCREEN; + if (ac.decl.behind) flags |= SPR_BEHIND; + if (ac.decl.ghost) flags |= SPR_GHOST; + if (a[3] && ts.isObjectLiteralExpression(a[3])) { + for (const p of a[3].properties) { + if (ts.isPropertyAssignment(p) && ts.isIdentifier(p.name) && p.name.text === "flip") { + if (p.initializer.kind === ts.SyntaxKind.TrueKeyword) flags |= SPR_HFLIP; + else flags &= ~SPR_HFLIP; + } + } + } + w.u8(OP.SPRITE_SHOW).u8(ac.slot).u8(ac.proto).i16(x).i16(y).u8(flags); + return; + } + case "hide": + w.u8(OP.SPRITE_HIDE).u8(actor(a[0]).slot); + return; + case "animate": { + const ac = actor(a[0]); + const mode = ts.isStringLiteral(a[1]) ? 1 : 0; + const arg = mode === 1 ? (a[2] ? num(a[2]) : 0) : num(a[1]); + w.u8(OP.SPRITE_ANIM).u8(ac.slot).u8(mode).u8(arg); + return; + } + case "moveTo": { + const ac = actor(a[0]); + w.u8(OP.SPRITE_MOVE).u8(ac.slot).u8(easeArg(3 + 1, EASE_INOUT)).i16(num(a[1])).i16(num(a[2])).u16(num(a[3])); + return; + } + case "walkTo": { + const ac = actor(a[0]); + w.u8(OP.SPRITE_ANIM).u8(ac.slot).u8(1).u8(0); + w.u8(OP.TWEEN).u8(TW_SPRITE_BASE + ac.slot * 2).u8(EASE_LINEAR).i16(num(a[1])).u16(num(a[2])); + w.u8(OP.WAIT_TWEENS); + w.u8(OP.SPRITE_ANIM).u8(ac.slot).u8(0).u8(0); + return; + } + case "control": { + const ac = actor(a[0]); + const speed = a[2] ? numF(a[2]) : 1.5; + w.u8(OP.CONTROL).u8(ac.slot).i16(num(a[1])).u8(Math.max(1, Math.round(speed * 16))); + return; + } + case "mash": + w.u8(OP.MASH).u8(internVar(str(a[0]))).u16(num(a[1])); + return; + case "counter": + w.u8(OP.COUNTER).u8(internVar(str(a[0]))).u8(1).i16(num(a[1])).i16(num(a[2])); + return; + case "counterHide": + w.u8(OP.COUNTER).u8(0).u8(0).i16(0).i16(0); + return; + case "affineOn": + w.u8(OP.AFFINE).u8(actor(a[0]).slot).u8(1); + return; + case "affineOff": + w.u8(OP.AFFINE).u8(actor(a[0]).slot).u8(0); + return; + case "sfx": { + const id = SFXS[str(a[0])]; + if (id === undefined) return err(n, "unknown sfx"); + w.u8(OP.SFX).u8(id); + return; + } + case "gotoScene": { + const idx = ctx.sceneIndex.get(str(a[0])); + if (idx === undefined) return err(n, `unknown scene "${str(a[0])}"`); + w.u8(OP.GOTO_SCENE).u8(idx); + return; + } + case "world": + case "breakout": + // statement position: run it, discard the pushed result + emitValueYield(call, n); + w.u8(OP.POP); + return; + case "meterShow": + w.u8(OP.METER) + .u8(num(a[0])) + .u8(internVar(str(a[1]))) + .i16(num(a[2])) + .i16(num(a[3])) + .u8(num(a[4])) + .u8(1); + return; + case "meterHide": + w.u8(OP.METER).u8(num(a[0])).u8(0).i16(0).i16(0).u8(1).u8(0); + return; + case "warp": { + const dir = a[2] ? DIRS[str(a[2])] : DIR_DOWN; + if (dir === undefined) return err(n, "warp dir must be down|up|left|right"); + w.u8(OP.WARP).u8(num(a[0])).u8(num(a[1])).u8(dir); + return; + } + case "face": { + const dir = DIRS[str(a[1])]; + if (dir === undefined) return err(n, "face dir must be down|up|left|right"); + w.u8(OP.FACE).u8(actor(a[0]).slot).u8(dir); + return; + } + case "walk": + w.u8(OP.WALK).u8(actor(a[0]).slot).u8(num(a[1])).u8(num(a[2])); + return; + case "setFlag": + w.u8(OP.SET_FLAG).u8(internFlag(str(a[0]))); + return; + case "clrFlag": + w.u8(OP.CLR_FLAG).u8(internFlag(str(a[0]))); + return; + case "setVar": + if (ts.isIdentifier(a[1])) w.u8(OP.GET_VAR).u8(internVar(localName(a[1].text))); + else w.u8(OP.PUSH).i16(num(a[1])); + w.u8(OP.SET_VAR).u8(internVar(str(a[0]))); + return; + case "addVar": + w.u8(OP.ADD_VAR).u8(internVar(str(a[0]))).i16(num(a[1])); + return; + default: + return err(n, `unknown cue op ${call.name}()`); + } + }; + + const numF = (e: ts.Expression): number => { + if (ts.isNumericLiteral(e)) return Number(e.text); + if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(e.operand)) + return -Number(e.operand.text); + return err(e, "expected a number"); + }; + + const emitStmt = (s: ts.Statement): void => { + if (ts.isExpressionStatement(s)) { + const e = s.expression; + if (ts.isYieldExpression(e) && e.expression) { + const call = asCall(e.expression); + if (!call) return void err(s, "yield must wrap a cue op call"); + emitOp(call, s); + return; + } + return void err(s, "only `yield op(...)` expression statements are allowed"); + } + if (ts.isVariableStatement(s)) { + for (const d of s.declarationList.declarations) { + if (!d.initializer || !ts.isYieldExpression(d.initializer) || !d.initializer.expression) + return void err(s, "const x = yield (...) is the only declaration form"); + const call = asCall(d.initializer.expression); + if (!call) return void err(s, "expected a value-producing cue op"); + emitValueYield(call, s); + if (!ts.isIdentifier(d.name)) return void err(s, "destructuring not supported"); + w.u8(OP.SET_VAR).u8(internVar(localName(d.name.text))); + } + return; + } + if (ts.isIfStatement(s)) { + emitCond(s.expression); + w.u8(OP.JZ); + const jzAt = w.length; + w.u16(0); + emitBlock(s.thenStatement); + if (s.elseStatement) { + w.u8(OP.JMP); + const jmpAt = w.length; + w.u16(0); + w.patchU16(jzAt, base + w.length); + emitBlock(s.elseStatement); + w.patchU16(jmpAt, base + w.length); + } else { + w.patchU16(jzAt, base + w.length); + } + return; + } + if (ts.isWhileStatement(s)) { + const loopStart = w.length; + emitCond(s.expression); + w.u8(OP.JZ); + const jzAt = w.length; + w.u16(0); + breakStack.push([]); + emitBlock(s.statement); + w.u8(OP.JMP).u16(base + loopStart); + w.patchU16(jzAt, base + w.length); + for (const b of breakStack.pop()!) w.patchU16(b, base + w.length); + return; + } + if (ts.isBreakStatement(s)) { + if (!breakStack.length) return void err(s, "break outside while"); + w.u8(OP.JMP); + breakStack[breakStack.length - 1].push(w.length); + w.u16(0); + return; + } + if (ts.isReturnStatement(s)) { + w.u8(OP.JMP); + endJumps.push(w.length); + w.u16(0); + return; + } + if (ts.isBlock(s)) { + for (const st of s.statements) emitStmt(st); + return; + } + if (ts.isEmptyStatement(s)) return; + return void err(s, `unsupported statement kind ${ts.SyntaxKind[s.kind]}`); + }; + + const emitBlock = (s: ts.Statement): void => { + if (ts.isBlock(s)) for (const st of s.statements) emitStmt(st); + else emitStmt(s); + }; + + if (!body.body || !ts.isBlock(body.body)) throw new Error(`[${ctx.cueName}] cue body must be a block`); + for (const st of body.body.statements) emitStmt(st); + + for (const j of endJumps) w.patchU16(j, base + w.length); + w.u8(OP.END); + return w.toUint8Array(); +} diff --git a/saga/compiler/rom.ts b/saga/compiler/rom.ts new file mode 100644 index 0000000..5772639 --- /dev/null +++ b/saga/compiler/rom.ts @@ -0,0 +1,70 @@ +// saga/compiler/rom.ts — link the runtime + gen_data.c into a real .gba ROM +// and patch the header (BIOS logo bitmap + title + complement checksum) so it +// boots on hardware, not just emulators. + +import { $ } from "bun"; + +const RT = new URL("../runtime", import.meta.url).pathname; +const DIST = new URL("../dist", import.meta.url).pathname; + +// prettier-ignore +const GBA_LOGO = [ + 0x24, 0xff, 0xae, 0x51, 0x69, 0x9a, 0xa2, 0x21, 0x3d, 0x84, 0x82, 0x0a, 0x84, 0xe4, 0x09, 0xad, + 0x11, 0x24, 0x8b, 0x98, 0xc0, 0x81, 0x7f, 0x21, 0xa3, 0x52, 0xbe, 0x19, 0x93, 0x09, 0xce, 0x20, + 0x10, 0x46, 0x4a, 0x4a, 0xf8, 0x27, 0x31, 0xec, 0x58, 0xc7, 0xe8, 0x33, 0x82, 0xe3, 0xce, 0xbf, + 0x85, 0xf4, 0xdf, 0x94, 0xce, 0x4b, 0x09, 0xc1, 0x94, 0x56, 0x8a, 0xc0, 0x13, 0x72, 0xa7, 0xfc, + 0x9f, 0x84, 0x4d, 0x73, 0xa3, 0xca, 0x9a, 0x61, 0x58, 0x97, 0xa3, 0x27, 0xfc, 0x03, 0x98, 0x76, + 0x23, 0x1d, 0xc7, 0x61, 0x03, 0x04, 0xae, 0x56, 0xbf, 0x38, 0x84, 0x00, 0x40, 0xa7, 0x0e, 0xfd, + 0xff, 0x52, 0xfe, 0x03, 0x6f, 0x95, 0x30, 0xf1, 0x97, 0xfb, 0xc0, 0x85, 0x60, 0xd6, 0x80, 0x25, + 0xa9, 0x63, 0xbe, 0x03, 0x01, 0x4e, 0x38, 0xe2, 0xf9, 0xa2, 0x34, 0xff, 0xbb, 0x3e, 0x03, 0x44, + 0x78, 0x00, 0x90, 0xcb, 0x88, 0x11, 0x3a, 0x94, 0x65, 0xc0, 0x7c, 0x63, 0x87, 0xf0, 0x3c, 0xaf, + 0xd6, 0x25, 0xe4, 0x8b, 0x38, 0x0a, 0xac, 0x72, 0x21, 0xd4, 0xf8, 0x07, +]; + +function patchHeader(rom: Uint8Array, title: string): void { + if (rom.length < 0xc0) throw new Error("ROM too small for a GBA header"); + rom.set(GBA_LOGO, 0x04); + const t = title.replace(/[^\x20-\x7e]/g, "").toUpperCase().slice(0, 12).padEnd(12, "\0"); + for (let i = 0; i < 12; i++) rom[0xa0 + i] = t.charCodeAt(i); + rom[0xb2] = 0x96; + let sum = 0; + for (let a = 0xa0; a <= 0xbc; a++) sum += rom[a]; + rom[0xbd] = (-(0x19 + sum)) & 0xff; +} + +export interface BuildRomResult { + gba: string; + elf: string; + size: number; +} + +export async function buildRom(genDataC: string, outPath: string, title = "SAGA"): Promise { + await Bun.write(RT + "/gen_data.c", genDataC); + await $`mkdir -p ${DIST}`.quiet(); + + const elf = DIST + "/film.elf"; + const CFLAGS = [ + "-mcpu=arm7tdmi", + "-marm", + "-ffreestanding", + "-nostdlib", + "-O2", + "-fno-strict-aliasing", + "-Wall", + ]; + const sources = [ + `${RT}/crt0.s`, + ...[ + "main", "video", "irq", "input", "tween", "fx", "obj", "caption", "cue_vm", + "world", "breakout", "sfx", "debug", "gen_data", + ].map((m) => `${RT}/${m}.c`), + ]; + + await $`arm-none-eabi-gcc ${CFLAGS} -I${RT} -T${RT}/gba.ld ${sources} -lgcc -o ${elf}`.quiet(); + await $`arm-none-eabi-objcopy -O binary ${elf} ${outPath}`.quiet(); + + const rom = new Uint8Array(await Bun.file(outPath).arrayBuffer()); + patchHeader(rom, title); + await Bun.write(outPath, rom); + return { gba: outPath, elf, size: rom.length }; +} diff --git a/saga/compiler/text.ts b/saga/compiler/text.ts new file mode 100644 index 0000000..367f525 --- /dev/null +++ b/saga/compiler/text.ts @@ -0,0 +1,146 @@ +// saga/compiler/text.ts — caption text bank: wrap, tokenize, glyph interning. +// Captions are at most C_CAP_LINES x CAP_COLS cells; the compiler wraps and +// validates at build time so the runtime never measures anything. + +import { CAP_COLS, CAP_LINES, TOK_END, TOK_NL, ByteWriter } from "../spec/saga.ts"; +import { unifontGlyph, halfcellPixels } from "./cjk.ts"; + +// Anything outside printable ASCII goes through the fullwidth-glyph path and +// occupies 2 cells (halfwidth Unifont glyphs get a blank right half). +const isAscii = (ch: string): boolean => { + const cp = ch.codePointAt(0)!; + return cp >= 0x20 && cp <= 0x7e; +}; +const cellW = (ch: string): number => (isAscii(ch) ? 1 : 2); + +/** Wrap into <= maxLines lines of <= cols cells. Manual "\n" respected. */ +export function wrapText(text: string, cols = CAP_COLS, maxLines = CAP_LINES): string[] { + const out: string[] = []; + for (const para of text.split("\n")) { + let line = ""; + let w = 0; + // units: ASCII runs stay whole; CJK breaks anywhere + const units = para.match(/[\x21-\x7e]+| +|[^\x20-\x7e]/g) ?? []; + for (const u of units) { + const uw = [...u].reduce((n, c) => n + cellW(c), 0); + if (w + uw > cols && w > 0) { + out.push(line); + line = u === " " || /^ +$/.test(u) ? "" : u; + w = line ? uw : 0; + } else { + line += u; + w += uw; + } + } + out.push(line); + } + while (out.length && out[out.length - 1] === "") out.pop(); + if (out.length > maxLines) { + throw new Error(`caption overflows ${maxLines} lines: ${JSON.stringify(text)} -> ${JSON.stringify(out)}`); + } + return out; +} + +export class TextBank { + private ids = new Map(); + readonly entries: { raw: string; lines: string[] }[] = []; + /** fullwidth glyph interner: char -> glyph id */ + private glyphIds = new Map(); + readonly glyphChars: string[] = []; + + intern(text: string, opts: { cols?: number; maxLines?: number } = {}): number { + const key = text; + const hit = this.ids.get(key); + if (hit !== undefined) return hit; + const lines = wrapText(text, opts.cols ?? CAP_COLS, opts.maxLines ?? CAP_LINES); + const id = this.entries.length; + this.entries.push({ raw: text, lines }); + this.ids.set(key, id); + for (const line of lines) { + for (const ch of line) { + if (isAscii(ch)) continue; + if (!this.glyphIds.has(ch)) { + this.glyphIds.set(ch, this.glyphChars.length); + this.glyphChars.push(ch); + } + } + } + return id; + } + + tokenize(id: number): Uint8Array { + const w = new ByteWriter(); + const { lines } = this.entries[id]; + lines.forEach((line, i) => { + if (i > 0) w.u8(TOK_NL); + for (const ch of line) { + const cp = ch.codePointAt(0)!; + if (isAscii(ch)) { + w.u8(cp); + } else { + const gid = this.glyphIds.get(ch); + if (gid === undefined) throw new Error(`glyph not interned: ${ch}`); + w.u8(0x80 | ((gid >> 8) & 0x7f)).u8(gid & 0xff); + } + } + }); + w.u8(TOK_END); + return w.toUint8Array(); + } + + /** Glyph store: 95 ASCII halfcells + 2 halfcells per fullwidth glyph. + * Each halfcell = two stacked 4bpp 8x8 tiles (64 bytes), ink/bg indices. */ + bakeGlyphStore(ink: number, bg: number): Uint8Array { + const halfcells: Uint8Array[] = []; + const pack = (px: number[]): Uint8Array => { + const t = new Uint8Array(32); + for (let row = 0; row < 8; row++) + for (let c = 0; c < 4; c++) { + const lo = px[row * 8 + c * 2] & 0xf; + const hi = px[row * 8 + c * 2 + 1] & 0xf; + t[row * 4 + c] = lo | (hi << 4); + } + return t; + }; + const bake = (cp: number | null, half: 0 | 1): Uint8Array => { + const glyph = cp === null ? null : unifontGlyph(cp); + const [top, bottom] = halfcellPixels(glyph, half, ink, bg); + const out = new Uint8Array(64); + out.set(pack(top), 0); + out.set(pack(bottom), 32); + return out; + }; + for (let cp = 0x20; cp <= 0x7e; cp++) halfcells.push(bake(cp, 0)); + for (const ch of this.glyphChars) { + const cp = ch.codePointAt(0)!; + const g = unifontGlyph(cp); + if (g && g.width === 8) { + halfcells.push(bake(cp, 0), bake(null, 0)); // halfwidth glyph + blank right half + } else { + halfcells.push(bake(cp, 0), bake(cp, 1)); + } + } + const blob = new Uint8Array(halfcells.length * 64); + halfcells.forEach((hc, i) => blob.set(hc, i * 64)); + return blob; + } + + buildBlob(): { offs: number[]; blob: Uint8Array } { + const offs: number[] = []; + const parts: Uint8Array[] = []; + let cur = 0; + for (let i = 0; i < this.entries.length; i++) { + const t = this.tokenize(i); + offs.push(cur); + parts.push(t); + cur += t.length; + } + const blob = new Uint8Array(cur); + let o = 0; + for (const p of parts) { + blob.set(p, o); + o += p.length; + } + return { offs, blob }; + } +} diff --git a/saga/docs/imagegen-eval/README.md b/saga/docs/imagegen-eval/README.md new file mode 100644 index 0000000..a78a201 --- /dev/null +++ b/saga/docs/imagegen-eval/README.md @@ -0,0 +1,27 @@ +# PixelLab vs codex imagegen (gpt-image) — GBA asset head-to-head + +Same subjects, both tools, July 2026. PixelLab via `pixellab/client.ts` +(pixflux, fixed seeds 61001-61003); codex via +`codex exec --sandbox workspace-write --skip-git-repo-check ""` +driving its built-in `image_gen` tool (ChatGPT-login auth, no API key). + +| Subject | PixelLab | codex/gpt-image | +| --- | --- | --- | +| 1970s garage workshop, 240×160 background | `pixellab/garage-bg.png` — pixel-grid-true, flat color regions, quantizes to a 15-color palbank with almost no loss | `codex/garage-bg.png` — richer composition (window night sky, garage door, hanging bulb glow), self-downscaled to exactly 240×160, but soft gradient shading costs more under 15-color quantization | +| Full-body 32×48 character sprite | `pixellab/founder-spr.png` — native size, true alpha, clean 1px outline, correct RPG proportions | `codex/founder-spr.png` — decent character but on a #00ff00 chroma background (needs removal pass), edges smear at this scale | +| Late-70s beige computer prop, 64×48 | `pixellab/computer-obj.png` — crisp, reads instantly, true alpha | `codex/computer-obj.png` — good silhouette, same chroma/removal caveat | +| 3×4 walk-cycle sheet (codex only) | pixflux cannot produce multi-frame sheets | `codex/walksheet.png` — real 96×192 grid with consistent character, but rows/phases don't reliably match the requested down/left/right/up walk semantics; raw material, not drop-in | + +## Verdict + +- **Default to PixelLab** for everything that ships on the GBA: native target + size, real alpha, `view`/`direction`/`outline`/`negative` control, fixed + seeds → reproducible + cacheable via manifest. +- **Reach for codex/gpt-image** when a background needs composition/mood + control that pixflux won't follow, or as raw material for structured sheets + (then hand-curate cells). Budget ~90s/image and pin exact output filenames in + the prompt. +- **Neither draws exact marks** (the Vue-logo problem). Bake those from vector + geometry: `cine/film/bake-logo.ts`. + +Details + prompt recipes: `.claude/skills/pixel-art-pipeline/SKILL.md`. diff --git a/saga/docs/imagegen-eval/codex/computer-obj.png b/saga/docs/imagegen-eval/codex/computer-obj.png new file mode 100644 index 0000000..ce8a14b Binary files /dev/null and b/saga/docs/imagegen-eval/codex/computer-obj.png differ diff --git a/saga/docs/imagegen-eval/codex/founder-spr.png b/saga/docs/imagegen-eval/codex/founder-spr.png new file mode 100644 index 0000000..2607099 Binary files /dev/null and b/saga/docs/imagegen-eval/codex/founder-spr.png differ diff --git a/saga/docs/imagegen-eval/codex/garage-bg.png b/saga/docs/imagegen-eval/codex/garage-bg.png new file mode 100644 index 0000000..c1907be Binary files /dev/null and b/saga/docs/imagegen-eval/codex/garage-bg.png differ diff --git a/saga/docs/imagegen-eval/codex/walksheet.png b/saga/docs/imagegen-eval/codex/walksheet.png new file mode 100644 index 0000000..7428324 Binary files /dev/null and b/saga/docs/imagegen-eval/codex/walksheet.png differ diff --git a/saga/docs/imagegen-eval/pixellab/computer-obj.png b/saga/docs/imagegen-eval/pixellab/computer-obj.png new file mode 100644 index 0000000..b0a4ea2 Binary files /dev/null and b/saga/docs/imagegen-eval/pixellab/computer-obj.png differ diff --git a/saga/docs/imagegen-eval/pixellab/founder-spr.png b/saga/docs/imagegen-eval/pixellab/founder-spr.png new file mode 100644 index 0000000..9fd7f87 Binary files /dev/null and b/saga/docs/imagegen-eval/pixellab/founder-spr.png differ diff --git a/saga/docs/imagegen-eval/pixellab/garage-bg.png b/saga/docs/imagegen-eval/pixellab/garage-bg.png new file mode 100644 index 0000000..d10c202 Binary files /dev/null and b/saga/docs/imagegen-eval/pixellab/garage-bg.png differ diff --git a/saga/dsl/index.ts b/saga/dsl/index.ts new file mode 100644 index 0000000..de868b6 --- /dev/null +++ b/saga/dsl/index.ts @@ -0,0 +1,226 @@ +// saga/dsl/index.ts — the @pocketjs/saga authoring surface. +// +// Two zones, same discipline as @pocketjs/aot: +// - the DECLARATION zone (defineFilm/defineScene/image/gradient/sprite) is +// executed at build time and fills a registry; +// - the RESIDUAL zone (`play: cue(function* () { ... })`) is never executed — +// the compiler lowers the generator's AST to cue bytecode. The op functions +// exported here are compile-time vocabulary; calling them outside a cue() +// body is an authoring error. + +export type Ease = "linear" | "in" | "out" | "inout"; +export type CaptionStyle = "chip" | "sub" | "card"; +export type Dir = "down" | "up" | "left" | "right"; + +export interface GradientDecl { + kind: "gradient"; + stops: string[]; +} +export interface LayerDecl { + kind: "image"; + png: string; + scroll?: number; // parallax factor vs camera (default: far .4, sky .15) + vx?: number; // autoscroll px/frame (fractions fine) + wide?: boolean; // main only: image wider than 240 -> 64x32 map + y?: number; // vertical placement offset in px (multiple of 8) +} +export interface ActorDecl { + png: string; + w: number; + h: number; + frames?: number; // horizontal strip + fps?: number; // anim frame period (frames per cell) + at?: [number, number]; + show?: boolean; + flip?: boolean; + ghost?: boolean; // OBJ semi-transparency + behind?: boolean; // prio 3, behind the main stage + screen?: boolean; // screen-space (HUD-like) + /** walker sheet: frames-per-direction. The strip holds 3*walkFpd frames in + * row order DOWN, UP, SIDE (right = SIDE hflipped); frame 0 of a row stands. */ + walkFpd?: number; +} + +// --- world (top-down grid) declarations ---------------------------------------- +// `at` accepts a letter naming cells in the grid, or explicit cells. +export type CellRef = string | [number, number]; +export type RectRef = string | [number, number, number, number]; // cx,cy,w,h + +export interface WorldPlayerDecl { + actor: string; + at: CellRef; + dir?: Dir; +} +export interface NpcDecl { + actor: string; + at: CellRef; + dir?: Dir; + talk?: CueRef; // run on A-interact + solid?: boolean; // default true +} +export interface ExitDecl { + at: RectRef; + value: number; // pushed as the result of `yield world()` +} +export interface SpotDecl { + at: RectRef; + run: CueRef; +} +export interface WorldDecl { + /** one string per cell row: '#' solid, '.'/' ' walkable, letters name cells + * (walkable). Must match the main image: ceil(w/16) x ceil(h/16). */ + grid: string[]; + player: WorldPlayerDecl; + npcs?: Record; + exits?: Record; // step onto -> world() returns value + spots?: Record; // face + A -> run cue (examine) + autos?: Record; // step onto (once) -> run cue +} + +export interface SceneDecl { + id: string; + sky?: GradientDecl | LayerDecl; + far?: LayerDecl; + main?: LayerDecl; + backdrop?: string; // hex color when no gradient (default #000000) + camera?: { start?: number; min?: number; max?: number }; + letterbox?: number; // initial bar height px + wave?: { layer: "main" | "far"; amp: number }; // raster sine on from scene start + actors?: Record; + /** presence makes this a WORLD scene: BG1 becomes a 64x64 walkable map + * (no far/sky layers), and `yield world()` hands input to the player. */ + world?: WorldDecl; + play: CueRef; +} + +export interface FilmDecl { + title: string; + scenes: SceneDecl[]; +} + +export interface CueRef { + __cue: number; +} + +export interface Registry { + film: FilmDecl | null; + scenes: SceneDecl[]; +} + +const REGISTRY: Registry = { film: null, scenes: [] }; + +export function __getRegistry(): Registry { + return REGISTRY; +} +export function __resetRegistry(): void { + REGISTRY.film = null; + REGISTRY.scenes = []; +} + +// --- declaration zone --------------------------------------------------------- + +export function defineScene(decl: SceneDecl): SceneDecl { + REGISTRY.scenes.push(decl); + return decl; +} + +export function defineFilm(decl: FilmDecl): FilmDecl { + if (REGISTRY.film) throw new Error("defineFilm() called twice"); + REGISTRY.film = decl; + return decl; +} + +export function image(png: string, opts: Omit = {}): LayerDecl { + return { kind: "image", png, ...opts }; +} + +export function gradient(...stops: string[]): GradientDecl { + if (stops.length < 2) throw new Error("gradient() needs at least 2 stops"); + return { kind: "gradient", stops }; +} + +export function sprite(png: string, opts: Omit): ActorDecl { + return { png, ...opts }; +} + +/** Residual generator marker. The compiler replaces the argument with an id. */ +export function cue(fn: number | (() => Generator)): CueRef { + if (typeof fn === "number") return { __cue: fn }; + throw new Error("cue() bodies are residual-only; compile with saga/compiler"); +} + +// --- residual zone vocabulary --------------------------------------------------- +// Every function below may ONLY appear inside cue(function* () { ... }) as +// `yield op(...)` (or in if/while conditions where noted). Bodies never run. + +const residual = (name: string) => (): never => { + throw new Error(`${name}() is residual-only — use it inside cue(function* () {...})`); +}; + +type R = unknown; + +// blocking +export const fadeIn = residual("fadeIn") as (frames?: number, color?: "black" | "white") => R; +export const fadeOut = residual("fadeOut") as (frames?: number, color?: "black" | "white") => R; +export const wait = residual("wait") as (frames: number) => R; +export const waitA = residual("waitA") as () => R; +export const waitTweens = residual("waitTweens") as () => R; +export const caption = residual("caption") as (style: CaptionStyle, text: string) => R; +export const dialog = residual("dialog") as (speaker: string, text: string) => R; +export const choice = residual("choice") as (options: string[]) => number; +export const walkTo = residual("walkTo") as (actor: string, x: number, frames: number) => R; +export const control = residual("control") as (actor: string, exitX: number, speed?: number) => R; +export const mash = residual("mash") as (varName: string, target: number) => R; + +// non-blocking +export const captionClear = residual("captionClear") as (style?: CaptionStyle | "all") => R; +export const pan = residual("pan") as (x: number, frames: number, ease?: Ease) => R; +export const panY = residual("panY") as (y: number, frames: number, ease?: Ease) => R; +export const alpha = residual("alpha") as (eva: number, evb: number, frames: number) => R; +export const mosaicTo = residual("mosaicTo") as (level: number, frames: number) => R; +export const shake = residual("shake") as (amp: number, frames: number) => R; +export const autoScroll = residual("autoScroll") as (layer: "far" | "sky", vx: number, frames?: number) => R; +export const zoom = residual("zoom") as (scale: number, frames: number, ease?: Ease) => R; +export const spinTo = residual("spinTo") as (angle: number, frames: number, ease?: Ease) => R; +export const letterbox = residual("letterbox") as (px: number, frames?: number) => R; +export const rasterWave = residual("rasterWave") as (layer: "main" | "far", amp: number) => R; +export const rasterGradient = residual("rasterGradient") as () => R; +export const rasterOff = residual("rasterOff") as () => R; +export const show = residual("show") as ( + actor: string, + x?: number, + y?: number, + opts?: { flip?: boolean }, +) => R; +export const hide = residual("hide") as (actor: string) => R; +export const animate = residual("animate") as (actor: string, mode: "loop" | number, fps?: number) => R; +export const moveTo = residual("moveTo") as (actor: string, x: number, y: number, frames: number, ease?: Ease) => R; +export const affineOn = residual("affineOn") as (actor: string) => R; +export const affineOff = residual("affineOff") as (actor: string) => R; +export const counter = residual("counter") as (varName: string, x: number, y: number) => R; +export const counterHide = residual("counterHide") as () => R; +export const sfx = residual("sfx") as (id: "blip" | "confirm" | "whoosh" | "star") => R; +export const gotoScene = residual("gotoScene") as (sceneId: string) => R; + +// world + encounters + minigames +export const world = residual("world") as () => number; // blocks; returns exit value +export const breakout = residual("breakout") as (rows: number, lives: number, frames?: number) => number; +export const meterShow = residual("meterShow") as (id: 0 | 1, varName: string, x: number, y: number, max: number) => R; +export const meterHide = residual("meterHide") as (id: 0 | 1) => R; +export const warp = residual("warp") as (cx: number, cy: number, dir?: Dir) => R; +export const face = residual("face") as (actor: string, dir: Dir) => R; +export const walk = residual("walk") as (actor: string, cx: number, cy: number) => R; + +// state (usable in conditions) +export const setFlag = residual("setFlag") as (name: string) => R; +export const clrFlag = residual("clrFlag") as (name: string) => R; +export const hasFlag = residual("hasFlag") as (name: string) => number; +export const setVar = residual("setVar") as (name: string, v: number) => R; +export const addVar = residual("addVar") as (name: string, d: number) => R; +export const varEq = residual("varEq") as (name: string, v: number) => number; +export const varNe = residual("varNe") as (name: string, v: number) => number; +export const varLt = residual("varLt") as (name: string, v: number) => number; +export const varGt = residual("varGt") as (name: string, v: number) => number; +export const varLe = residual("varLe") as (name: string, v: number) => number; +export const varGe = residual("varGe") as (name: string, v: number) => number; +export const rnd = residual("rnd") as (n: number) => number; diff --git a/saga/game/art/bg_atari.png b/saga/game/art/bg_atari.png new file mode 100644 index 0000000..eb91e75 Binary files /dev/null and b/saga/game/art/bg_atari.png differ diff --git a/saga/game/art/bg_dorm.png b/saga/game/art/bg_dorm.png new file mode 100644 index 0000000..5a4a262 Binary files /dev/null and b/saga/game/art/bg_dorm.png differ diff --git a/saga/game/art/bg_faire.png b/saga/game/art/bg_faire.png new file mode 100644 index 0000000..ba2cf2e Binary files /dev/null and b/saga/game/art/bg_faire.png differ diff --git a/saga/game/art/bg_orchard.png b/saga/game/art/bg_orchard.png new file mode 100644 index 0000000..7be1f5b Binary files /dev/null and b/saga/game/art/bg_orchard.png differ diff --git a/saga/game/art/bg_penthouse.png b/saga/game/art/bg_penthouse.png new file mode 100644 index 0000000..489cfd8 Binary files /dev/null and b/saga/game/art/bg_penthouse.png differ diff --git a/saga/game/art/bg_reed.png b/saga/game/art/bg_reed.png new file mode 100644 index 0000000..ef6debf Binary files /dev/null and b/saga/game/art/bg_reed.png differ diff --git a/saga/game/art/bg_stage84.png b/saga/game/art/bg_stage84.png new file mode 100644 index 0000000..2cd08af Binary files /dev/null and b/saga/game/art/bg_stage84.png differ diff --git a/saga/game/art/bg_title.png b/saga/game/art/bg_title.png new file mode 100644 index 0000000..9ae5ad8 Binary files /dev/null and b/saga/game/art/bg_title.png differ diff --git a/saga/game/art/manifest.json b/saga/game/art/manifest.json new file mode 100644 index 0000000..b1250dd --- /dev/null +++ b/saga/game/art/manifest.json @@ -0,0 +1,236 @@ +{ + "map_garage68": { + "prompt": "top-down 2D RPG interior map of a 1960s American suburban garage, seen directly from above like a Game Boy RPG town map, clean 16x16 tile alignment, top-down 2D RPG interior map, brown wooden plank walls forming the room border, smooth gray concrete floor, a long wooden workbench with hand tools and a vise along the top wall, a pegboard, an old sedan car parked on the right half, cardboard boxes and an oil can in a corner, a wall telephone near the top left door", + "seed": 62010, + "w": 320, + "h": 240 + }, + "map_garage76": { + "prompt": "top-down 2D RPG interior map of a 1970s American garage turned into a tiny electronics workshop, seen directly from above like a Game Boy RPG town map, clean 16x16 tile alignment, top-down 2D RPG interior map, brown wooden plank walls forming the room border, gray concrete floor, two long assembly tables covered with bare green circuit boards and soldering irons, a burn-in rack of boards along one wall, stacked cardboard shipping boxes, a wall telephone near the top left door, warm work lamps", + "seed": 62011, + "w": 320, + "h": 240 + }, + "map_parc": { + "prompt": "top-down 2D RPG interior map of a 1970s corporate research laboratory, seen directly from above like a Game Boy RPG town map, clean 16x16 tile alignment, top-down 2D RPG interior map, clean beige walls, light gray carpet floor, several white desks in an open plan, on one central desk a tall white computer with a vertical portrait monitor and a small box mouse on a pad, bookshelves along the top wall, a beanbag corner, potted plants", + "seed": 62012, + "w": 320, + "h": 240 + }, + "map_bandley": { + "prompt": "top-down 2D RPG interior map of an early 1980s open-plan software office, seen directly from above like a Game Boy RPG town map, clean 16x16 tile alignment, top-down 2D RPG interior map, white walls, blue-gray carpet, desks with small beige computers and keyboards, a whiteboard on the top wall, a couch and coffee table corner with a rubber plant, pizza boxes on one desk, a tall flag pole in one corner", + "seed": 62013, + "w": 320, + "h": 240 + }, + "bg_title": { + "prompt": "quiet California suburban street at dusk, side view: a modest ranch house with its garage door half open and glowing warm from inside, silhouetted fruit trees, purple-orange sky, first stars, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62020, + "w": 240, + "h": 160 + }, + "bg_dorm": { + "prompt": "1971 college dorm room at night, side view: a narrow bed, a wooden desk crowded with electronic parts, a soldering iron, loose wires and a small blue metal box, one desk lamp, a rotary phone on the wall, posters, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62021, + "w": 240, + "h": 160 + }, + "bg_reed": { + "prompt": "sunlit college calligraphy classroom, side view: tall windows with dust motes, wooden drafting desks with ink pots and wide paper sheets showing flowing black pen strokes, a blackboard with elegant swash strokes drawn in chalk, warm morning light, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62022, + "w": 240, + "h": 160 + }, + "bg_atari": { + "prompt": "1970s video game company workshop at night, side view: a row of dark arcade cabinets along the back wall, a workbench with an oscilloscope and circuit boards, one bright desk lamp pool of light, dark blue shadows, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62023, + "w": 240, + "h": 160 + }, + "bg_faire": { + "prompt": "1977 computer trade show hall, side view: a convention booth with a clean beige home computer with integrated keyboard on a draped table, colorful pennant banners overhead, crowd barriers, bright show lighting, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62024, + "w": 240, + "h": 160 + }, + "bg_penthouse": { + "prompt": "Manhattan penthouse balcony at dusk, side view: stone balustrade in the foreground, vast city skyline with lit windows below, hazy orange-to-blue gradient sky, two empty chairs and a small table with glasses, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62025, + "w": 240, + "h": 160 + }, + "bg_stage84": { + "prompt": "dark auditorium stage, side view: one strong spotlight cone on a small table at center stage with a canvas bag on it, huge dark projection screen behind, front rows of audience as black silhouettes, deep blue darkness, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62026, + "w": 240, + "h": 160 + }, + "bg_orchard": { + "prompt": "rolling green orchard hills at sunrise, side view: rows of small fruit trees on soft hills, morning mist in the valley, pale gold sky with a rising sun, one dirt path, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62027, + "w": 240, + "h": 160 + }, + "walk_hero_s": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a young man in his early twenties, shoulder-length dark brown hair, short beard, white collared shirt, blue jeans, brown sandals, standing still, arms at sides, facing the viewer", + "seed": 62030, + "w": 32, + "h": 32 + }, + "walk_hero_n": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a young man in his early twenties, shoulder-length dark brown hair, short beard, white collared shirt, blue jeans, brown sandals, standing still, seen from behind", + "seed": 62031, + "w": 32, + "h": 32 + }, + "walk_hero_e": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a young man in his early twenties, shoulder-length dark brown hair, short beard, white collared shirt, blue jeans, brown sandals, standing still, side profile facing right", + "seed": 62032, + "w": 32, + "h": 32 + }, + "walk_kid_s": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a twelve year old boy, short dark hair, orange striped t-shirt, blue shorts, sneakers, standing still, arms at sides, facing the viewer", + "seed": 62133, + "w": 32, + "h": 32 + }, + "walk_kid_n": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a twelve year old boy, short dark hair, orange striped t-shirt, blue shorts, sneakers, standing still, seen from behind", + "seed": 62234, + "w": 32, + "h": 32 + }, + "walk_kid_e": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a twelve year old boy, short dark hair, orange striped t-shirt, blue shorts, sneakers, standing still, side profile facing right", + "seed": 62135, + "w": 32, + "h": 32 + }, + "walk_dad_s": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a man in his fifties, short crew cut hair, plaid work shirt, gray trousers, standing still, arms at sides, facing the viewer", + "seed": 62036, + "w": 32, + "h": 32 + }, + "walk_dad_n": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a man in his fifties, short crew cut hair, plaid work shirt, gray trousers, standing still, seen from behind", + "seed": 62037, + "w": 32, + "h": 32 + }, + "walk_dad_e": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a man in his fifties, short crew cut hair, plaid work shirt, gray trousers, standing still, side profile facing right", + "seed": 62038, + "w": 32, + "h": 32 + }, + "walk_woz_s": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a stocky young man, dark shaggy hair, full beard, square glasses, dark green shirt, jeans, standing still, arms at sides, facing the viewer", + "seed": 62039, + "w": 32, + "h": 32 + }, + "walk_woz_n": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a stocky young man, dark shaggy hair, full beard, square glasses, dark green shirt, jeans, standing still, seen from behind", + "seed": 62040, + "w": 32, + "h": 32 + }, + "walk_woz_e": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a stocky young man, dark shaggy hair, full beard, square glasses, dark green shirt, jeans, standing still, side profile facing right", + "seed": 62041, + "w": 32, + "h": 32 + }, + "walk_res_s": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a man in his thirties, brown mustache, light blue button-up shirt, dark slacks, standing still, arms at sides, facing the viewer", + "seed": 62042, + "w": 32, + "h": 32 + }, + "walk_res_n": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a man in his thirties, brown mustache, light blue button-up shirt, dark slacks, standing still, seen from behind", + "seed": 62043, + "w": 32, + "h": 32 + }, + "walk_res_e": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a man in his thirties, brown mustache, light blue button-up shirt, dark slacks, standing still, side profile facing right", + "seed": 62044, + "w": 32, + "h": 32 + }, + "walk_team_s": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a young woman, dark bobbed 80s hair, red sweater, dark skirt, standing still, arms at sides, facing the viewer", + "seed": 62045, + "w": 32, + "h": 32 + }, + "walk_team_n": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a young woman, dark bobbed 80s hair, red sweater, dark skirt, standing still, seen from behind", + "seed": 62046, + "w": 32, + "h": 32 + }, + "walk_team_e": { + "prompt": "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style of a young woman, dark bobbed 80s hair, red sweater, dark skirt, standing still, side profile facing right", + "seed": 62047, + "w": 32, + "h": 32 + }, + "port_sculley": { + "prompt": "pixel art bust portrait of a confident American executive in his mid forties, neat side-parted light brown hair, navy suit, striped tie, slight guarded smile, plain dark background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62050, + "w": 64, + "h": 64 + }, + "port_supplier": { + "prompt": "pixel art bust portrait of a skeptical older parts salesman, balding with gray temples, thick glasses, short-sleeve white shirt with a pen in the pocket, plain dark background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62051, + "w": 64, + "h": 64 + }, + "port_hero": { + "prompt": "pixel art bust portrait of a young man in his early twenties, shoulder-length dark brown hair, short beard, white collared shirt, blue jeans, brown sandals, intense dark eyes, faint smile, plain dark background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62052, + "w": 64, + "h": 64 + }, + "spr_bluebox": { + "prompt": "one small handheld blue metal electronic box with a white keypad of round buttons, top-down slight angle, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62060, + "w": 32, + "h": 32 + }, + "spr_board": { + "prompt": "one bare green computer circuit board with rows of black chips and golden traces, slight angle, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62061, + "w": 32, + "h": 32 + }, + "spr_alto": { + "prompt": "one 1970s white research computer with a tall vertical portrait monitor and a small keyboard, front view, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62062, + "w": 32, + "h": 32 + }, + "spr_mac": { + "prompt": "one small friendly beige all-in-one computer, compact vertical case with a small built-in screen glowing soft white and a detached keyboard in front, front view, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62063, + "w": 32, + "h": 32 + }, + "spr_flag": { + "prompt": "one black pirate flag with a white skull, waving on a short pole, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62064, + "w": 32, + "h": 32 + }, + "spr_phone": { + "prompt": "one 1960s black rotary desk telephone with a coiled cord, slight angle, transparent background, clean 16-bit pixel art, limited palette, crisp pixels", + "seed": 62065, + "w": 32, + "h": 32 + } +} \ No newline at end of file diff --git a/saga/game/art/map_bandley.png b/saga/game/art/map_bandley.png new file mode 100644 index 0000000..03ed2a0 Binary files /dev/null and b/saga/game/art/map_bandley.png differ diff --git a/saga/game/art/map_garage68.png b/saga/game/art/map_garage68.png new file mode 100644 index 0000000..5e373a9 Binary files /dev/null and b/saga/game/art/map_garage68.png differ diff --git a/saga/game/art/map_garage76.png b/saga/game/art/map_garage76.png new file mode 100644 index 0000000..8b414e7 Binary files /dev/null and b/saga/game/art/map_garage76.png differ diff --git a/saga/game/art/map_parc.png b/saga/game/art/map_parc.png new file mode 100644 index 0000000..60c2fa4 Binary files /dev/null and b/saga/game/art/map_parc.png differ diff --git a/saga/game/art/port_hero.png b/saga/game/art/port_hero.png new file mode 100644 index 0000000..533a7ec Binary files /dev/null and b/saga/game/art/port_hero.png differ diff --git a/saga/game/art/port_sculley.png b/saga/game/art/port_sculley.png new file mode 100644 index 0000000..446326c Binary files /dev/null and b/saga/game/art/port_sculley.png differ diff --git a/saga/game/art/port_supplier.png b/saga/game/art/port_supplier.png new file mode 100644 index 0000000..cc13a07 Binary files /dev/null and b/saga/game/art/port_supplier.png differ diff --git a/saga/game/art/spr_alto.png b/saga/game/art/spr_alto.png new file mode 100644 index 0000000..15d7afe Binary files /dev/null and b/saga/game/art/spr_alto.png differ diff --git a/saga/game/art/spr_bluebox.png b/saga/game/art/spr_bluebox.png new file mode 100644 index 0000000..2552f6c Binary files /dev/null and b/saga/game/art/spr_bluebox.png differ diff --git a/saga/game/art/spr_board.png b/saga/game/art/spr_board.png new file mode 100644 index 0000000..b8f7532 Binary files /dev/null and b/saga/game/art/spr_board.png differ diff --git a/saga/game/art/spr_dad.png b/saga/game/art/spr_dad.png new file mode 100644 index 0000000..07da235 Binary files /dev/null and b/saga/game/art/spr_dad.png differ diff --git a/saga/game/art/spr_flag.png b/saga/game/art/spr_flag.png new file mode 100644 index 0000000..0aaf231 Binary files /dev/null and b/saga/game/art/spr_flag.png differ diff --git a/saga/game/art/spr_hero.png b/saga/game/art/spr_hero.png new file mode 100644 index 0000000..fe9a823 Binary files /dev/null and b/saga/game/art/spr_hero.png differ diff --git a/saga/game/art/spr_kid.png b/saga/game/art/spr_kid.png new file mode 100644 index 0000000..1d5c361 Binary files /dev/null and b/saga/game/art/spr_kid.png differ diff --git a/saga/game/art/spr_mac.png b/saga/game/art/spr_mac.png new file mode 100644 index 0000000..54f9fd0 Binary files /dev/null and b/saga/game/art/spr_mac.png differ diff --git a/saga/game/art/spr_phone.png b/saga/game/art/spr_phone.png new file mode 100644 index 0000000..f050e0b Binary files /dev/null and b/saga/game/art/spr_phone.png differ diff --git a/saga/game/art/spr_res.png b/saga/game/art/spr_res.png new file mode 100644 index 0000000..631195e Binary files /dev/null and b/saga/game/art/spr_res.png differ diff --git a/saga/game/art/spr_team.png b/saga/game/art/spr_team.png new file mode 100644 index 0000000..4bbdbe2 Binary files /dev/null and b/saga/game/art/spr_team.png differ diff --git a/saga/game/art/spr_woz.png b/saga/game/art/spr_woz.png new file mode 100644 index 0000000..8efa92b Binary files /dev/null and b/saga/game/art/spr_woz.png differ diff --git a/saga/game/art/walk_dad_e.png b/saga/game/art/walk_dad_e.png new file mode 100644 index 0000000..b6da635 Binary files /dev/null and b/saga/game/art/walk_dad_e.png differ diff --git a/saga/game/art/walk_dad_n.png b/saga/game/art/walk_dad_n.png new file mode 100644 index 0000000..77546a1 Binary files /dev/null and b/saga/game/art/walk_dad_n.png differ diff --git a/saga/game/art/walk_dad_s.png b/saga/game/art/walk_dad_s.png new file mode 100644 index 0000000..ce4e995 Binary files /dev/null and b/saga/game/art/walk_dad_s.png differ diff --git a/saga/game/art/walk_hero_e.png b/saga/game/art/walk_hero_e.png new file mode 100644 index 0000000..133c6f6 Binary files /dev/null and b/saga/game/art/walk_hero_e.png differ diff --git a/saga/game/art/walk_hero_n.png b/saga/game/art/walk_hero_n.png new file mode 100644 index 0000000..338c0d7 Binary files /dev/null and b/saga/game/art/walk_hero_n.png differ diff --git a/saga/game/art/walk_hero_s.png b/saga/game/art/walk_hero_s.png new file mode 100644 index 0000000..23f893d Binary files /dev/null and b/saga/game/art/walk_hero_s.png differ diff --git a/saga/game/art/walk_kid_e.png b/saga/game/art/walk_kid_e.png new file mode 100644 index 0000000..92de50c Binary files /dev/null and b/saga/game/art/walk_kid_e.png differ diff --git a/saga/game/art/walk_kid_n.png b/saga/game/art/walk_kid_n.png new file mode 100644 index 0000000..c95c95b Binary files /dev/null and b/saga/game/art/walk_kid_n.png differ diff --git a/saga/game/art/walk_kid_s.png b/saga/game/art/walk_kid_s.png new file mode 100644 index 0000000..4b2d9bb Binary files /dev/null and b/saga/game/art/walk_kid_s.png differ diff --git a/saga/game/art/walk_res_e.png b/saga/game/art/walk_res_e.png new file mode 100644 index 0000000..61413af Binary files /dev/null and b/saga/game/art/walk_res_e.png differ diff --git a/saga/game/art/walk_res_n.png b/saga/game/art/walk_res_n.png new file mode 100644 index 0000000..bd355be Binary files /dev/null and b/saga/game/art/walk_res_n.png differ diff --git a/saga/game/art/walk_res_s.png b/saga/game/art/walk_res_s.png new file mode 100644 index 0000000..ea29825 Binary files /dev/null and b/saga/game/art/walk_res_s.png differ diff --git a/saga/game/art/walk_team_e.png b/saga/game/art/walk_team_e.png new file mode 100644 index 0000000..c38dbb1 Binary files /dev/null and b/saga/game/art/walk_team_e.png differ diff --git a/saga/game/art/walk_team_n.png b/saga/game/art/walk_team_n.png new file mode 100644 index 0000000..22f6ed2 Binary files /dev/null and b/saga/game/art/walk_team_n.png differ diff --git a/saga/game/art/walk_team_s.png b/saga/game/art/walk_team_s.png new file mode 100644 index 0000000..feabe6d Binary files /dev/null and b/saga/game/art/walk_team_s.png differ diff --git a/saga/game/art/walk_woz_e.png b/saga/game/art/walk_woz_e.png new file mode 100644 index 0000000..c823e9a Binary files /dev/null and b/saga/game/art/walk_woz_e.png differ diff --git a/saga/game/art/walk_woz_n.png b/saga/game/art/walk_woz_n.png new file mode 100644 index 0000000..90d6490 Binary files /dev/null and b/saga/game/art/walk_woz_n.png differ diff --git a/saga/game/art/walk_woz_s.png b/saga/game/art/walk_woz_s.png new file mode 100644 index 0000000..b13a9e6 Binary files /dev/null and b/saga/game/art/walk_woz_s.png differ diff --git a/saga/game/dossier.md b/saga/game/dossier.md new file mode 100644 index 0000000..6240064 --- /dev/null +++ b/saga/game/dossier.md @@ -0,0 +1,170 @@ +# Steve Jobs, 1955–1984: A Sourced Dossier for "Part One" + +> Research basis for 《REALITY DISTORTION — Part One》. Compiled 2026-07-07 by a +> five-pass web-research sweep plus an adversarial verification pass; all +> load-bearing quotes checked against primary or near-primary text (Isaacson +> full text, TIME's archive, the *Odyssey* scan, Folklore.org, the 1984 +> shareholders-meeting video, woz.org) rather than quote aggregators. +> The game states nothing from the DISPUTED list as fact. + +**Legend.** **[V]** VERIFIED (primary source or 2+ independent solid sources) · **[S]** REPORTED-BUT-SINGLE-SOURCE · **[D]** DISPUTED (conflicting accounts — do not state as fact). + +**Core primary sources.** [Isaacson, *Steve Jobs* (2011), full text](https://archive.org/details/steve-jobs-walter-isaacson_202601) · [Stanford commencement, June 12, 2005](https://news.stanford.edu/stories/2005/06/youve-got-find-love-jobs-says) · ["The Lost Interview" 1995, transcript](https://sameerbajaj.com/jobs/) · [*Triumph of the Nerds* transcript, PBS 1996](https://www.pbs.org/nerds/part3.html) · [Folklore.org (Hertzfeld & Mac team)](https://www.folklore.org) · Wozniak, *iWoz* (2006) · [TIME, Jan 3, 1983](https://content.time.com/time/subscriber/article/0,33009,953633,00.html) · [Sculley, *Odyssey* (1987), scan](https://archive.org/details/odysseypepsitoap00scul) · [Jan 24, 1984 shareholders-meeting video](https://archive.org/details/1984complete) · Playboy interview, Feb 1985. + +--- + +## 1. Timeline of verified events + +### Childhood (1955–1968) + +- **Feb 24, 1955 — Born in San Francisco** to Joanne Schieble and Abdulfattah "John" Jandali; adopted at birth by Paul (machinist, high-school dropout) and Clara Jobs (bookkeeper). **[V]** A lawyer couple backed out wanting a girl; the Jobses got the middle-of-the-night call. Schieble refused to sign until they "promised that I would someday go to college." Source: Jobs' own telling, [Stanford 2005](https://news.stanford.edu/stories/2005/06/youve-got-find-love-jobs-says); details in Isaacson ch. 1. +- **1959 or 1961 — Family moves to 286 Diablo Ave., Mountain View.** **[D]** on date: county records say owned 1959–1967 ([Mountain View Voice](https://www.mv-voice.com/news/2011/10/07/steve-jobs-called-mountain-view-home-as-a-child/)); Isaacson says 1961. The house is a Mackay tract home by Eichler's architects — a "Likeler," not a true Eichler as Jobs believed ([Eichler Network](https://www.eichlernetwork.com/article/jobs-likeler-no-eichler)). **[D]** +- **Early 1960s — Paul Jobs' workbench.** "Steve, this is your workbench now" — Paul marked off part of his garage worktable and taught craftsmanship: "You've got to make the back of the fence that nobody will see just as good looking as the front." **[S]** — first-party (Jobs to Isaacson, ch. 1) but a single channel. +- **4th grade — teacher Imogene "Teddy" Hill** "bribed me into learning" (candy, $5); Jobs skipped 5th grade; bullied at Crittenden Middle; ultimatum forced the family move. **[V]** 1995 Smithsonian oral history + Isaacson. +- **1967 — Family moves to 2066 Crist Drive, Los Altos** (1952 ranch house, attached garage) for the Cupertino school district. **[V]** Isaacson; [CNN 2013 historic-designation coverage](https://edition.cnn.com/2013/10/30/tech/innovation/steve-jobs-historic-home). +- **c. 1968 — The Bill Hewlett call.** Jobs finds Hewlett in the Palo Alto phone book: "Hi. My name's Steve Jobs. You don't know me, but I'm 12 years old, and I'm building a frequency counter, and I'd like some spare parts" ([Lost Interview 1995](https://sameerbajaj.com/jobs/)). Hewlett laughed, gave him the parts and a summer job on the HP frequency-counter assembly line ([1994 SVHA interview via CNBC](https://www.cnbc.com/2018/07/25/how-steve-jobs-cold-called-his-way-to-an-internship-at-hewlett-packard.html)). Event **[V]**; his exact age/timing **[D]** (Jobs: 12; Isaacson places the job after freshman year, ~13–14). +- **c. 1968 — HP Explorers Club:** sees his first desktop computer, the HP 9100A — "It was huge, maybe forty pounds, but it was a beauty of a thing. I fell in love with it" (Isaacson). **[V]** + +### Homestead High and Wozniak (1968–1972) + +- **Fall 1968–June 1972 — Homestead High School, Cupertino.** Takes John McCollum's Electronics 1 with Bill Fernandez; clashes with the ex-Navy teacher and drops out of the class. McCollum: Jobs was "usually off in a corner doing something on his own" (Moritz, *Return to the Little Kingdom*; [EE Times](https://www.eetimes.com/john-mccollum-hs-teacher-taught-electronics-with-love/)). **[V]** +- **1970–71 — Meets Steve Wozniak** via Bill Fernandez, on the sidewalk in front of Fernandez's Sunnyvale house while Woz washed his car ([Fernandez in Call-A.P.P.L.E.](https://www.callapple.org/call-apple/an-a-p-p-l-e-exclusive-interview-bill-fernandez-life-in-the-apple-world/); iWoz: "we sat on the sidewalk… for the longest time… mostly about pranks"). Bonded over pranks and Bob Dylan. Woz + Fernandez had just built the "Cream Soda Computer." Meeting **[V]**; exact year and remembered ages **[D]** (standard: 1971, Jobs 16, Woz 21; Jobs recalled being 13–15). +- **June 1972 — "SWAB JOB" graduation banner prank** (Wozniak-Baum-Jobs initials), foiled before unfurling. **[V]** iWoz; [Wharton interview](https://knowledge.wharton.upenn.edu/article/steve-wozniak-on-apple-steve-jobs-and-the-value-of-a-good-prank/). + +### Blue boxes (1971–1973) + +- **Oct 1971 — Esquire publishes Ron Rosenbaum's "Secrets of the Little Blue Box."** Woz reads it the day before Berkeley fall classes, calls Jobs mid-article; next day they find the MF frequencies in a book at the SLAC library — Woz: "I froze and grabbed Steve and nearly screamed in excitement that I'd found it." **[V]** [Original article](https://explodingthephone.com/docs/dbx0092.pdf); Lapsley, *Exploding the Phone* ([Salon excerpt](https://www.salon.com/2013/02/16/from_phreaks_to_apple_steve_jobs_and_steve_wozniaks_eureka_moment/)). +- **Fall 1971 — Woz builds the first all-digital blue box** ("We built the best blue box in the world… all digital. No adjustments" — Jobs, 1995). They meet Captain Crunch (John Draper) in Woz's Berkeley dorm. Woz places the Henry Kissinger prank call to the Vatican ("you're not Henry Kissinger. I just spoke to Henry Kissinger"). **[V]** iWoz; [NPR 2009](https://www.npr.org/2009/01/03/98977379/playful-pranks-from-apples-founder); Playboy 1985. Who was woken at the Vatican varies by teller. **[D]** (minor) +- **1971–72 — Sales.** Jobs drives commercialization; parts ~$40, priced $150–$170. Quantity **[D]**: Woz says 30–40; Jobs claimed ~100. Robbed of one at gunpoint outside a Sunnyvale pizza parlor, 1972 (iWoz). **[V]** for the robbery as Woz's account. + +### Reed, Atari, India (1972–1975) + +- **Sept 1972 — Enrolls at Reed College, Portland.** Drops out after one semester ("after the first 6 months"), stays ~18 months as a drop-in: sleeps on friends' floors, returns Coke bottles for 5¢, walks 7 miles Sundays to the Hare Krishna temple. **[V]** [Stanford 2005](https://news.stanford.edu/stories/2005/06/youve-got-find-love-jobs-says); [Reed's own page](https://www.reed.edu/about/steve-jobs.html) (room 32, Westport, Old Dorm Block). +- **1972–74 — The calligraphy class.** Taught by **Robert Palladino** (ran Reed's program 1969–1984, succeeding Lloyd Reynolds) — [Reed calligraphy history](https://www.reed.edu/calligraphy/history.html). Jobs was an informal drop-in, not a registered auditor. Stanford 2005: "If I had never dropped in on that single course in college, the Mac would have never had multiple typefaces or proportionally spaced fonts." **[V]** — but note Reed's own Jobs page misattributes the teaching to Reynolds. **[D]** between Reed's pages; evidence favors Palladino. +- **Early 1974 — Hired at Atari, Los Gatos** — technician, $5/hour, "like, employee number 40" (Playboy 1985). Refused to leave the lobby until hired; Al Alcorn: "He was this real scuzzy kid… I figured I'd hire him" ([Game Developer](https://www.gamedeveloper.com/business/steve-jobs-atari-employee-number-40)). Moved to night shift over hygiene/abrasiveness complaints. **[V]**; hire month Feb vs May 1974 **[D]**. +- **Mid–late 1974 — India, ~7 months,** with Daniel Kottke; Neem Karoli Baba had died (Sept 1973) — "The Neem Karoli ashram was completely deserted" ([Kottke interview](https://medium.com/learning-for-life/searching-for-magic-in-india-and-silicon-valley-an-interview-with-daniel-kottke-f2690148d0c8)). Head-shaving by a baba: Jobs-only anecdote (Playboy 1985). **[S]** Returns in Indian cotton, head shaved; Zen practice deepens afterward (Kobun Chino). +- **1975 — Breakout.** Bushnell offers Jobs the job: $750 base + $100/chip under 50 (Bushnell's account). Jobs enlists Woz; four days and nights; breadboard at 44–46 chips — too dense for Atari to manufacture; Atari ships its own ~100-chip version (arcade release May 13, 1976). Woz's telling: Jobs said it paid $700 and paid Woz $350. The reported ~$5,000 bonus to Jobs first appeared in Scott Cohen's *Zap!* (1984); Woz learned from the book a decade later — "I cried… I just wish he had been honest" ([BBC 2011 via IBTimes](https://www.ibtimes.co.uk/steve-wozniak-cried-jobs-kept-atari-bonus-267711)). Jobs to Isaacson: "I don't know where that allegation comes from. I gave him half the money I ever got." Episode **[V]**; the exact $5,000 figure **[D]** (traces to *Zap!*, corroborated in outline by Bushnell/Alcorn, no payroll record). + +### Apple's founding (1975–1977) + +- **March 5, 1975 — First Homebrew Computer Club meeting,** Gordon French's garage, Menlo Park; later biweekly at the SLAC auditorium. Woz: "After my first meeting, I started designing the computer that would later be known as the Apple I" (iWoz). Jobs attended only occasionally. **[V]** [Woz's essay](https://www.atariarchives.org/deli/homebrew_and_how_the_apple.php); [CHM](https://www.computerhistory.org/revolution/personal-computers/17/312). +- **June 29, 1975 — Apple I first boot,** ~10 PM: first time a character typed on a keyboard appeared on a personal computer's own screen ([Smithsonian](https://www.smithsonianmag.com/smithsonian-institution/steve-wozniaks-apple-i-booted-up-tech-revolution-180958112/)). **[V]** +- **April 1, 1976 — Apple Computer founded** by partnership agreement: Jobs 45%, Wozniak 45%, **Ron Wayne 10%** (tie-breaker); Wayne draws the Newton-under-apple-tree logo and writes the Apple I manual ([Sotheby's lot](https://www.sothebys.com/en/auctions/ecatalogue/2011/fine-books-manuscripts-n08811/lot.241.html)). **April 12, 1976** — Wayne withdraws for **$800** (later +$1,500 = $2,300 total), fearing unlimited partnership liability; scarred by his own failed 1971 slot-machine company (NOT Osborne). **[V]** [CNBC](https://www.cnbc.com/2018/08/02/why-ronald-wayne-sold-his-10-percent-stake-in-apple-for-800-dollars.html). +- Funding: Jobs sells his VW bus (~$1,500; some accounts $750 **[D]**), Woz his HP-65 (nominally $500, buyer only paid half). ~$1,300 raised is the standard figure. **[D]** on exact amounts. +- **Late March/April 1976 — The Byte Shop order.** Day after seeing Woz's Homebrew demo, a barefoot Jobs visits Paul Terrell in Mountain View: "I'm keeping in touch." Terrell orders **50 assembled, tested boards at $500 each, COD — $25,000** (not complete computers: no case/keyboard/PSU). Terrell: "The big thing that I did for him was convince him… he ought to actually assemble and test it… then sell it for $500" ([NextShark/Terrell interviews](https://nextshark.com/paul-terrell-apple)). Parts on net-30 credit (Cramer vs Kierulff **[D]**; a July 15, 1976 Apple check to Kierulff exists — [RR Auction](https://www.rrauction.com/auctions/lot-detail/345625006328020-steve-jobs-and-steve-wozniak-signed-1976-apple-computer-check/)); plus a $5,000 loan from Allen & Elmer Baum (Apr 6, 1976). Assembly at the Jobs house — bedroom first, then the garage — Patti Jobs and Dan Kottke at $1/board; delivered within the 30-day window. **[V]** overall; exact first-delivery date unpinned. Apple I retail: **$666.66** (Woz liked repeating digits). **[V]** ~200 built, ~175 sold (Woz, BYTE Dec 1984). +- **The garage's actual role. [V]** Woz, Bloomberg Businessweek, Dec 2014: "**The garage is a bit of a myth. We did no designs there, no breadboarding, no prototyping, no planning of products. We did no manufacturing there.**" Design happened at his HP cubicle and apartment; the garage was final test + a place that felt like home ([Bloomberg](https://www.bloomberg.com/news/articles/2014-12-05/video-steve-wozniak-on-what-really-happened-in-steve-jobs-garage)). +- **Jan 3, 1977 — Apple Computer, Inc. incorporates.** **Mike Markkula** (via Don Valentine — "Why did you send me this renegade from the human race?", widely retold, exact utterance unpinnable **[S]**) guarantees a **$250,000 line of credit for one-third**; Jobs, Woz, Markkula each 26% (Isaacson's account; other accounts add ~$91–92k personal equity **[D]** on structure). Michael Scott hired as president, Feb 1977. **[V]** + +### Apple II to IPO (1977–1980) + +- **April 16–17, 1977 — Apple II debuts at the West Coast Computer Faire,** San Francisco Civic Auditorium/Brooks Hall. Prime booth facing the entrance; backlit Plexiglas sign with Rob Janoff's new rainbow logo; only 3 finished units — Jobs stacks a dozen empty Jerry Manock cases to fake inventory ([Exhibit City News](https://exhibitcitynews.com/tradeshows-work-apple-computer-case-study/); apple2history). $1,298 (4K), 6502, color graphics, Rod Holt's fanless switching PSU; ships June 10, 1977. **[V]** (empty-cases detail: 2 secondary retellings — near-verified) +- **May 17, 1978 — Lisa Nicole Brennan born** on Robert Friedland's All One Farm, Oregon (see §4). **[V]** +- **Nov–Dec 1979 — The Xerox PARC visits (two).** Deal: Xerox Development Corp. buys **100,000 pre-IPO Apple shares for ~$1M** (share price $10 vs $10.50 **[D]**) in exchange for the technology showing (Gladwell, ["Creation Myth," New Yorker 2011](https://www.newyorker.com/magazine/2011/05/16/creation-myth); Hiltzik, *Dealers of Lightning*). Jobs attends the second visit; first demo was sanitized; Xerox HQ ordered the full Smalltalk demo over Adele Goldberg's objections ("I had a big argument with these Xerox executives telling them that they were about to give away the kitchen sink" — [PBS](https://www.pbs.org/nerds/part3.html)). Jobs (Tesler's 2014 CHM account): "**You're sitting on a gold mine! Why aren't you doing something with this technology? You could change the world!**" ([Fortune/CHM footage](https://fortune.com/2014/08/24/raw-footage-larry-tesler-on-steve-jobs-visit-to-xerox-parc/)). Jobs later: "They showed me really three things. But I was so blinded by the first one I didn't even really see the other two" (*Triumph of the Nerds*). **[V]** +- **Fall 1980 — Jobs pushed off the Lisa project** in Michael Scott's reorg; John Couch gets Lisa; Jobs made board chairman. Fact **[V]**; exact date **[D]** (Sept 1980 mainstream vs CHM's "1982"). +- **Dec 8, 1980 — Paternity case settled** (see §4). **Dec 12, 1980 — IPO:** 4.6M shares at $22 (Morgan Stanley + Hambrecht & Quist); biggest IPO since Ford 1956; ~300 people become millionaires; Jobs' 7.5M shares ≈ $217M ([CHM](https://www.computerhistory.org/tdih/december/12/); [Morgan Stanley](https://ourhistory.morganstanley.com/stories/new-horizons-with-new-challenges/story-1980-apple)). Massachusetts barred retail sale as too risky ([contemporaneous clipping](https://www.ped30.com/2017/12/12/apple-ipo-massachusetts/)). Woz sold ~2,000-share blocks cheap to early employees (the "Woz Plan"). **[V]** (Woz Plan specifics **[S]**) + +### Macintosh (1979–1984) + +- **Sept 1979 — Jef Raskin formally starts the Macintosh project** (pitched to Markkula March 1979), named for his favorite apple, spelling changed to avoid McIntosh Laboratory hi-fi ([Folklore: The Father of The Macintosh](https://www.folklore.org/The_Father_of_The_Macintosh.html); [Macworld](https://www.macworld.com/article/669214/how-the-macintosh-got-its-name.html)). Raskin's vision: ultra-low-cost appliance, no mouse. Burrell Smith's Dec 1980 board: 68000 @ 8 MHz via "bus transformer" PAL trick ([Folklore: Five Different Macs](https://www.folklore.org/Five_Different_Macs.html)). **[V]** +- **Jan 1981 — Jobs takes over the Mac project.** **Feb 19, 1981** — Raskin's memo to Mike Scott, "Working for/with Steve Jobs" ("regularly misses appointments… does not give credit where due"); Raskin leaves Apple 1982. **[V]** [Cult of Mac](https://www.cultofmac.com/news/mac-creator-jef-raskin-disses-steve-jobs); Stanford Making-the-Macintosh archive. +- **Feb 1981 — "Reality distortion field" coined by Bud Tribble** to Andy Hertzfeld: "It's a term from Star Trek. **Steve has a reality distortion field. In his presence, reality is malleable. He can convince anyone of practically anything.**" **[V]** [Folklore](https://www.folklore.org/Reality_Distortion_Field.html). +- **Feb 10, 1982 — Signing party:** ~47 Mac Division signatures engraved inside the case mold. **[V]** [Folklore](https://www.folklore.org/Signing_Party.html). +- **Jan 3, 1983 — TIME "Machine of the Year"** issue; the Jobs profile ("The Updated Book of Jobs," Jay Cocks, reporting Michael Moritz) publicizes Lisa's existence; Jobs: "I read the article, and it was so awful that I actually cried" (Isaacson). **[V]** +- **Jan 19, 1983 — Lisa launches at $9,995** ([CHM](https://computerhistory.org/blog/the-lisa-apples-most-influential-failure/)). Backronym "Local Integrated Software/Systems Architecture" reverse-engineered from the name; Jobs to Isaacson: "**Obviously it was named for my daughter.**" **[V]** +- **Jan 27–28, 1983 — Mac team retreat, La Playa Hotel, Carmel.** Jobs' sayings on the flip chart: "**Real Artists Ship**," "**It's Better To Be A Pirate Than Join The Navy**," "Mac in a Book by 1986." **[V]** [Folklore: Credit Where Due](https://www.folklore.org/Credit_Where_Due.html); [Quote Investigator](https://quoteinvestigator.com/2018/10/13/ship/). +- **March–April 1983 — Recruiting John Sculley** (headhunter Gerry Roche). On the terrace of the San Remo penthouse **Jobs** was buying (not Sculley's building), Jobs asks: "**Do you want to spend the rest of your life selling sugared water or do you want a chance to change the world?**" (*Odyssey*, 1987 — printed wording verified via [archive.org scan](https://archive.org/details/odysseypepsitoap00scul)). Sculley named president/CEO **April 8, 1983**; ~$1M salary+bonus, $1M signing, $1M parachute, 350,000 options. **[V]** (west-balcony/Hudson detail is *Odyssey*-only **[S]**) +- **Aug 1983 — The pirate flag** raised over Bandley 3 the night before the Mac team moves in: black cloth sewn by **Steve Capps**, skull and crossbones painted by **Susan Kare**, eyepatch a rainbow Apple logo decal; later stolen by the Lisa team as a prank. **[V]** [Folklore: Pirate Flag](https://www.folklore.org/Pirate_Flag.html). +- **Oct 1983 — Honolulu sales conference:** "1984 won't be like '1984'" bit, first screening of the 1984 ad, and the "Software Dating Game" with Bill Gates, Mitch Kapor, Fred Gibbons. **[V]** [allaboutstevejobs video](https://allaboutstevejobs.com/videos/keynotes/hawaii_1983); [Slate](https://slate.com/business/2013/11/steve-jobs-and-bill-gates-on-the-dating-game-in-1983.html). +- **Dec 1983 — "1984" ad pre-airs once on KMVT, Twin Falls, Idaho** to qualify for ad awards. Date **[D]**: Folklore says 1 AM Dec 15; Wikipedia says Dec 31, last break before midnight. +- **Jan 22, 1984 — "1984" airs nationally once,** third quarter of Super Bowl XVIII (CBS; Raiders 38–9). Director Ridley Scott; Chiat/Day (Steve Hayden copy, Lee Clow CD, Brent Thomas AD); Anya Major; Apple's board hated it ("the worst commercial that he had ever seen" — a director, per [Folklore: 1984](https://www.folklore.org/1984.html)); Chiat/Day sold back only the 30s slot. Production cost **[D]**: $250k–$900k range across sources. **[V]** core facts. +- **Jan 24, 1984 — Macintosh launch, Flint Center, De Anza College, Cupertino** (~2,400 seats), at Apple's annual shareholders meeting ([full video](https://archive.org/details/1984complete)). Sequence: Jobs (28, "finely tailored black suit complete with a prominent bow tie" — Hertzfeld) recites the second verse of Dylan's "The Times They Are A-Changin'"; routine proxy business; Sculley's FY1983 report; then Jobs unveils the Mac from a canvas bag, inserts a floppy; on-screen demo montage (scrolling MACINTOSH, MacPaint art by Kare, "insanely great!" skywriter) to the *Chariots of Fire* theme from a CD (Hertzfeld's synthesized version was rejected "as lousy, which it was"); the 1984 ad brings the crowd to its feet; then the Mac speaks (text by 1984-ad copywriter Steve Hayden; run on one of only two 512K prototype Macs): "**Hello, I am Macintosh. It sure is great to get out of that bag! Unaccustomed as I am to public speaking, I'd like to share with you a maxim I thought of the first time I met an IBM mainframe: Never trust a computer that you can't lift! Obviously, I can talk, but right now I'd like to sit back and listen. So it is with considerable pride that I introduce a man who has been like a father to me... Steve Jobs!**" Five minutes of pandemonium; Jobs "obviously holding back tears." **[V]** [Folklore: The Times They Are A-Changin'](https://www.folklore.org/The_Times_They_Are_A-Changin.html), [Folklore: Intro Demo](https://folklore.org/Intro_Demo.html). Price $2,495. Encore performance at the Boston Computer Society, Jan 30, 1984. **[V]** + +--- + +## 2. Iconic quotes with provenance + +| Quote (documented wording) | Provenance | Status | +|---|---|---| +| "You've got to make the back of the fence that nobody will see just as good looking as the front." | Paul Jobs, via Steve Jobs' interviews with Isaacson (ch. 1) | [S] one channel | +| "I'm 12 years old… I'm building a frequency counter, and I'd like some spare parts." | Jobs, Lost Interview 1995 | [V] | +| "If we hadn't made blue boxes, there would have been no Apple." | Jobs, 1994 Silicon Valley Historical Assoc. interview — earliest form | [V] | +| "I don't think there would have ever been an Apple computer had there not been blue boxing." | Jobs, Lost Interview 1995 | [V] | +| "If it hadn't been for the Blue Boxes, there wouldn't have been an Apple. I'm 100% sure of that." | Jobs to Isaacson (2011) | [V] — note three variants; attribute each to its source. A Woz-voiced version is a misattribution | +| "If I had never dropped in on that single course in college, the Mac would have never had multiple typefaces or proportionally spaced fonts." | Stanford commencement, June 12, 2005 | [V] | +| "The Apple II will be dead in a few years… The Macintosh is the future." | Jobs to Hertzfeld, [Folklore: Black Wednesday](https://www.folklore.org/Black_Wednesday.html) (Feb 1981) | [V] | +| "Steve has a reality distortion field… In his presence, reality is malleable." | Bud Tribble, Feb 1981, via [Folklore](https://www.folklore.org/Reality_Distortion_Field.html) | [V] | +| "You're sitting on a gold mine! Why aren't you doing something with this technology?" | Jobs at PARC, Dec 1979, per Larry Tesler ([CHM 2014 footage](https://fortune.com/2014/08/24/raw-footage-larry-tesler-on-steve-jobs-visit-to-xerox-parc/)) and Bill Atkinson ([Pogue 2025](https://pogueman.substack.com/p/bill-atkinsons-last-interview)) | [V] wording varies by teller | +| "They showed me really three things. But I was so blinded by the first one I didn't even really see the other two." | Jobs on camera, *Triumph of the Nerds* pt. 3 (PBS 1996) | [V] | +| "Real Artists Ship" / "It's Better To Be A Pirate Than Join The Navy" | Jobs, Carmel retreat Jan 27–28, 1983, per [Folklore: Credit Where Due](https://www.folklore.org/Credit_Where_Due.html) | [V] | +| "Do you want to spend the rest of your life selling sugared water or do you want a chance to change the world?" | Sculley, *Odyssey* (1987) — Sculley's memoir of Jobs' words, March 1983 | [V] as Sculley's account; oral retellings say "sugar water" | +| "Never trust a computer that you can't lift!" | The Macintosh (voice demo), Jan 24, 1984 — [Folklore transcript](https://folklore.org/Intro_Demo.html) + [video](https://archive.org/details/1984complete); text written by Steve Hayden | [V] | +| "Why did you send me this renegade from the human race?" | Don Valentine to Regis McKenna, 1976 — retold by Valentine/Isaacson | [S] anecdote solid, utterance unpinnable | +| "The garage is a bit of a myth. We did no designs there…" | Wozniak, [Bloomberg Businessweek, Dec 2014](https://www.bloomberg.com/news/articles/2014-12-05/video-steve-wozniak-on-what-really-happened-in-steve-jobs-garage) | [V] | +| "I don't know where that allegation comes from. I gave him half the money I ever got." | Jobs to Isaacson (Breakout bonus) — verified against printed text | [V] as Jobs' denial; the facts remain disputed | +| "Obviously it was named for my daughter." | Jobs to Isaacson, on the Lisa | [V] | + +--- + +## 3. Unverified / disputed / apocryphal — do not state as fact + +1. **"Apple was founded in a garage."** Woz 2014: no design, prototyping, or manufacturing happened there. Say: Apple I boards were *assembled/tested* at the Jobs house (bedroom, then garage). **[D]** +2. **Breakout bonus = exactly $5,000.** Figure originates in *Zap!* (1984); Bushnell/Alcorn corroborate a chip bonus existed; no payroll record. Woz's $350 is his consistent first-party figure ("we only got 700 bucks for it," BYTE 1984). Jobs' denial exists (Isaacson) — the popular wording "half of every dime" is a corruption. **[D]** +3. **Blue box counts/prices** — 30–40 units (Woz) vs ~100 (Jobs); $150 vs $170. Wikipedia's "two hundred" is an outlier/likely error. **[D]** +4. **A Wozniak-voiced "no blue boxes, no Apple" quote** — not found in any first-party source; the line is Jobs'. **Misattribution.** +5. **"Palladino didn't remember Jobs"** — FALSE. Palladino remembered him warmly in multiple interviews ([WaPo 2016](https://www.washingtonpost.com/news/arts-and-entertainment/wp/2016/03/08/the-trappist-monk-whose-calligraphy-inspired-steve-jobs-and-influenced-apples-designs/)). The true irony: Palladino appears nowhere in Isaacson, and Apple rebuffed his letters. Also, Reed's own Jobs page wrongly credits Lloyd Reynolds as his teacher. +6. **Jobs/Woz meeting ages** — Jobs remembered 13–15; standard account is 1971 (Jobs 16, Woz 21). **[D]** +7. **Atari hire month** (Feb vs May 1974); **VW bus sale price** ($1,500 vs $750) and the "bus later broke down" anecdote (no citable source — folklore). **[D]/apocryphal** +8. **Ron Wayne quit because of Osborne memories** — wrong; it was his own failed 1971 Las Vegas slot-machine company. **Corrected.** +9. **Flint Center "2,571 seats"** — no source; capacity ~2,400 (Hertzfeld: "up to 2,500"). A named backstage operator and "fear it would crash" during the Mac-speaks demo — not in Folklore; the demo disk self-ran (on a 512K prototype Mac, not a Lisa). **Unsourced.** +10. **KMVT airing date** — Dec 15, 1983, 1 AM (Folklore) vs Dec 31, 1983 (Wikipedia and most retellings). **[D]** +11. **Xerox share price** $10.00 vs $10.50; **which reorg removed Jobs from Lisa** (Sept 1980 vs 1982); **Markkula's structure** (pure credit-line guarantee per Isaacson vs ~$91k cash + guarantee per Moritz-lineage). **[D]** +12. **Adele Goldberg "I had a fit"** — apocryphal paraphrase; her on-camera words are the "kitchen sink" argument. **Raskin's "$500 Mac" price target** — his papers say ~$500, Folklore says only "ultra low cost." **[D]** +13. **Paternity-test percentage** — cite 94.41% to Isaacson; TIME printed 94.1%, *Small Fry* says 94.4%. **[D]** (trivial but real) +14. **Apple I demo date that hooked Terrell** and first Byte Shop delivery date — no primary source pins either day. **Unpinned.** +15. **Mac speech wording** — use the Folklore/video wording ("Hello, I am Macintosh… a computer that you can't lift"); common retellings ("I'm Macintosh," "a computer you can't lift") drift. + +--- + +## 4. Sensitive matters in this period + +**Lisa Brennan paternity.** Lisa Nicole Brennan was born May 17, 1978 on the All One Farm; Jobs denied paternity ("It's not my kid," per *Small Fry*) while Chrisann Brennan lived on welfare in Menlo Park. San Mateo County sued; Jobs swore in a court filing he was "sterile and infertile" ([Fortune 2008](https://fortune.com/2008/03/05/the-trouble-with-steve-jobs/)); a 1979 UCLA test put paternity probability at 94.41% (Isaacson). Jobs told TIME's reporter "28% of the male population of the United States could be the father" ([TIME, Jan 3, 1983](https://content.time.com/time/subscriber/article/0,33009,953633,00.html)). The case closed **Dec 8, 1980** — four days before the IPO — with $385/month support (later raised to $500), medical insurance, and $5,856 welfare repayment (Isaacson; [*Small Fry* excerpt, Vanity Fair](https://www.vanityfair.com/news/2018/08/lisa-brennan-jobs-small-fry-steve-jobs-daughter)). He later admitted the Lisa computer was named for her. *Game guidance: if depicted at all, depict without euphemism or invention; the verified beats above are the only safe ones. Omitting is defensible; smoothing is not.* + +**LSD.** Jobs told John Markoff that taking LSD was "one of the two or three most important things" he had done in his life (*What the Dormouse Said*, 2005), and told Isaacson: "Taking LSD was a profound experience, one of the most important things in my life." Both are first-party, on-record statements from the subject himself. *Game guidance: a GBA-rated tribute can allude to the counterculture era (Dylan, India, Zen) without depicting drug use; do not invent scenes around it.* + +**The Breakout payment.** Woz designed Breakout in four days/nights in 1975 for a job Jobs said paid $700, and received $350; Scott Cohen's *Zap!* (1984) reported Jobs also received a bonus (~$5,000) he never mentioned; Bushnell and Alcorn corroborate a chip-count bonus existed. Woz, on learning of it a decade later: "I cried… I just wish he had been honest" (BBC 2011). Jobs denied it to Isaacson: "I gave him half the money I ever got." Woz has repeatedly said he'd have done it for free and holds no grudge. *Game guidance: present both accounts or neither; never adjudicate. The friendship survived — that's the documented arc.* + +**Adoption.** Jobs was placed for adoption at birth by two unmarried graduate students; the arranged lawyer-couple wanted a girl; Paul and Clara Jobs took the call and later promised Joanne Schieble he'd attend college (Stanford 2005; Isaacson ch. 1). Jobs bristled lifelong at "abandoned" framing — he told Isaacson that Paul and Clara "were my parents 1,000%." *Game guidance: use Jobs' own Stanford framing ("connecting the dots"); avoid armchair psychology about abandonment.* + +--- + +## 5. Scene candidates (ranked for an interactive tribute) + +1. **The Mac speaks — Flint Center, Jan 24, 1984.** Verifiable to the second (video + Folklore). *Player: carry the Mac onstage in its canvas bag, insert the floppy, and time the reveal beats to the crowd.* Traps: it's a shareholders meeting, not a standalone keynote; 512K prototype ran the speech; use exact Folklore wording; capacity ~2,400. +2. **The blue box eureka — SLAC library, Oct 1971.** Verified by both principals. *Player: scan shelves of phone-company journals to find the MF frequency table before closing time.* Traps: Woz built the box, Jobs commercialized; don't show Jobs soldering it; unit counts disputed. +3. **The Hewlett call, c. 1968.** First-party (Jobs 1995 + HP's account). *Player: dial through the Palo Alto phone book and deliver the pitch line by choosing dialogue beats.* Traps: age 12 vs 13 disputed — leave age unstated (the quoted line itself says 12; attribute it). +4. **29 days of boards — bedroom, then garage, 1976.** Terrell order verified. *Player: assemble/test Apple I boards against a 30-day credit clock with Patti and Kottke.* Traps: boards, not complete computers; don't show design work in the garage (Woz 2014); supplier name disputed. +5. **Xerox PARC demo, Dec 1979.** Tesler/Atkinson/Goldberg all on record. *Player: as Jobs, spot the three revelations on the Alto screen — but the game only lets you "see" the first one.* Traps: two visits, Jobs on the second; Apple paid with pre-IPO stock access — not theft, a deal. +6. **Calligraphy drop-in — Reed, 1972–73.** Stanford 2005 + Reed records. *Player: trace serif letterforms with the D-pad; years later the same strokes become Mac fonts.* Traps: teacher is Palladino, not Reynolds; Jobs was an informal drop-in, technically dropped out already. +7. **Breakout, four nights at Atari, 1975.** Verified core; disputed money. *Player: as Woz, shrink a board chip-by-chip across four dawns while Jobs runs the breadboard wires.* Traps: do not put a dollar figure on Jobs' bonus; Atari never shipped Woz's design; date is 1975 (design), 1976 (release). +8. **The pirate flag over Bandley 3, Aug 1983.** Folklore-verified. *Player: as Capps, climb to the roof at midnight and raise Kare's skull-and-rainbow-eyepatch flag.* Traps: flag made by Capps/Kare, not Jobs; the saying is from the Jan 1983 Carmel retreat, a separate scene. +9. **"Sugared water" — San Remo terrace, March 1983.** Sculley's memoir. *Player: as Sculley, hesitate through dialogue options until the one question ends the scene.* Traps: Jobs' building, not Sculley's; use *Odyssey* wording; it is Sculley's account — frame as his memory. +10. **Homebrew demo → "I'm keeping in touch," spring 1976.** Woz + Terrell accounts. *Player: demo the Apple I at SLAC, then walk barefoot into the Byte Shop next morning and negotiate 50 units COD.* Traps: Terrell ordered assembled boards at $500; demo date unpinned — keep it vague. +11. **Paul's workbench, early 1960s.** Isaacson-channel only, but emotionally foundational. *Player: as young Steve, sand the back of a fence nobody will ever see.* Traps: single-source; keep it quiet and small. +12. **Kainchi ashram, 1974.** Kottke-corroborated. *Player: arrive after a long walk to find the ashram deserted — the guru died last autumn; choose what to carry home.* Traps: head-shaving is Jobs-only [S]; "returned a Buddhist" is embellishment. + +--- + +## 6. Period visual details for pixel art + +- **2066 Crist Drive, Los Altos:** single-story 1952 ranch, low-pitched roof, attached street-facing garage with plain roll-up door, low hedge, wide suburban lot. Photos: [CNN 2013](https://edition.cnn.com/2013/10/30/tech/innovation/steve-jobs-historic-home), Wikimedia "Steve Jobs house." +- **Apple I:** bare green PCB (~60+ ICs), no case/keyboard/PSU; buyers added wooden enclosures — Byte Shop commissioned **koa-wood cases** ([Smithsonian unit](https://americanhistory.si.edu/collections/object/nmah_1692121); [Apple-1 Registry](https://www.apple1registry.com/)). +- **Apple II:** beige wedge ("Apple Beige," spec'd from Pantone 453), integrated keyboard, 45° chamfers, rainbow-logo badge; Jerry Manock case ([apple2history](https://www.apple2history.org/2012/02/10/pantone-453/), [V&A object](https://collections.vam.ac.uk/item/O1461690/apple-ii-apple-ii-computer-steve-jobs/)). +- **Blue box:** pocket-size plastic box, 12/13-key keypad, speaker, 9V battery; Woz's guarantee slip inside: "He's got the whole world in his hands." Woz's unit at CHM ([catalog](https://www.computerhistory.org/collections/catalog/102627263)); another at [The Henry Ford](https://www.thehenryford.org/collections-and-research/digital-collections/artifact/452666/). +- **Homebrew venue:** SLAC auditorium (later "Panofsky Auditorium") — 1960s concrete lecture hall; [SLAC's own 1978 meeting photo](https://www.flickr.com/photos/slaclab/12224644563). +- **Byte Shop, 1063 W. El Camino Real, Mountain View:** opened Dec 8, 1975; strip-mall storefront; period photos in the [San José Public Library California Room](https://www.metrosiliconvalley.com/first-byte-shop-mountain-view-paul-terrell-steve-jobs/) and Terrell's own Dec 1976 interior shots (TIME Techland). Sign-color details unverified — consult photos. +- **Reed College:** red-brick Tudor-Gothic (Eliot Hall, Old Dorm Block, A.E. Doyle 1912); "every poster, every label on every drawer, was beautifully hand calligraphed" (Stanford 2005). +- **Atari, Los Gatos, 1974:** warehouse engineering shop lined with Pong cabinets, "Have fun, make money" want-ad culture, barefoot night-shift techs ([Game Developer](https://www.gamedeveloper.com/business/steve-jobs-atari-employee-number-40)). Vibe details [S]. +- **Xerox Alto:** portrait 606×808 bitmap CRT on tilt/swivel base, detachable keyboard, **three-button mouse**, optional 5-key chord keyset, fridge-sized CPU cabinet under the desk ([CHM photo](https://commons.wikimedia.org/wiki/File:Xerox_Alto_I_(1973)_workstation_console_(606x808_pixel_bitmap_display,_keyboard,_5-key_chorded_keyboard,_and_3-button_mouse)_-_Computer_History_Museum_(2007-11-10_23.09.48_by_Carlo_Nardone).jpg)). +- **Bandley 3, Cupertino:** low-rise early-80s office on Bandley Drive; Jolly Roger with rainbow-Apple eyepatch on the roof ([Folklore photo/story](https://www.folklore.org/Pirate_Flag.html)). No source for an atrium — don't draw one. +- **Flint Center stage, Jan 24, 1984:** dark stage, lone table with the bagged Mac, giant projection screen, Jobs in black double-breasted suit + bow tie ([video](https://archive.org/details/1984complete)). +- **Macintosh 128K:** beige vertical unit 13.6"×9.6"×10.9", 9" B&W 512×342 screen, floppy slot below-right, recessed top handle, no fan, keyboard **without arrow keys**, one-button mouse; team signatures molded inside; "hello" in MacPaint script in launch materials. Beige ≈ #C4C0AA, deliberately off-Pantone-453 ([color history](https://bzotto.medium.com/what-color-was-apple-beige-acd14bca0c1a); [Macintosh 128K](https://en.wikipedia.org/wiki/Macintosh_128K)). diff --git a/saga/game/reality-distortion.ts b/saga/game/reality-distortion.ts new file mode 100644 index 0000000..826baa7 --- /dev/null +++ b/saga/game/reality-distortion.ts @@ -0,0 +1,938 @@ +// saga/game/reality-distortion.ts — REALITY DISTORTION, Part One (1955-1984). +// +// An interactive fan tribute to Steve Jobs, from Paul Jobs' workbench to the +// Macintosh launch at the Flint Center. English-language, GBA-only. +// +// Factual discipline: every dated event follows game/dossier.md (source-cited; +// disputed items are avoided or framed). Two kinds of lines appear in the +// game: ORIGINAL fan writing (most dialogue), and DOCUMENTED lines used +// verbatim where the dossier verifies wording (the Hewlett call pitch, the +// PARC "gold mine" shout, the Carmel retreat sayings, the sugared-water +// question per Sculley's memoir, and the Macintosh's own 1984 speech). +// The credits say exactly that. + +import { + defineFilm, defineScene, cue, image, gradient, sprite, + fadeIn, fadeOut, wait, waitA, waitTweens, caption, captionClear, dialog, choice, + pan, panY, letterbox, mosaicTo, shake, alpha, zoom, spinTo, show, hide, animate, + moveTo, walkTo, control, mash, counter, counterHide, affineOn, affineOff, sfx, gotoScene, + setFlag, clrFlag, hasFlag, setVar, addVar, varEq, varNe, varLt, varGt, varLe, varGe, + world, breakout, meterShow, meterHide, warp, face, walk, +} from "@pocketjs/saga"; + +// --------------------------------------------------------------------------- +// 0 · TITLE +// --------------------------------------------------------------------------- +const title = defineScene({ + id: "title", + main: image("art/bg_title.png"), + backdrop: "#0a0a14", + play: cue(function* () { + yield setVar("nav", 0); + yield fadeIn(45); + // the persistent title lives in the chip style: chips own a private + // glyph-slot range, so the menu text below can never corrupt it + yield caption("chip", "REALITY DISTORTION"); + yield caption("card", "PART ONE\n1955 - 1984"); + yield wait(40); + yield caption("sub", "A fan tribute. Original\nwriting; not affiliated."); + yield wait(40); + yield captionClear("card"); + yield captionClear("sub"); + while (yield varEq("nav", 0)) { + const c = yield choice(["Play", "Chapters"]); + if (c === 0) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(30); + yield gotoScene("garage62"); + } + if (c === 1) { + const p = yield choice(["The Workbench", "The Blue Box", "The Letterform", "Breakout", "More..."]); + if (p === 0) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(20); + yield gotoScene("garage62"); + } + if (p === 1) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(20); + yield gotoScene("bluebox"); + } + if (p === 2) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(20); + yield gotoScene("reed"); + } + if (p === 3) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(20); + yield gotoScene("atari"); + } + if (p === 4) { + const q = yield choice(["Fifty Boards", "The Goldmine", "Sugared Water", "Hello", "Back"]); + if (q === 0) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(20); + yield gotoScene("garage76"); + } + if (q === 1) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(20); + yield gotoScene("parc"); + } + if (q === 2) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(20); + yield gotoScene("sculley"); + } + if (q === 3) { + yield setVar("nav", 1); + yield captionClear("all"); + yield fadeOut(20); + yield gotoScene("bandley"); + } + } + } + } + }), +}); + +// --------------------------------------------------------------------------- +// 1 · THE WORKBENCH — Mountain View garage, early 1960s (world) +// --------------------------------------------------------------------------- +const garage62 = defineScene({ + id: "garage62", + main: image("art/map_garage68.png"), + backdrop: "#141018", + actors: { + kid: sprite("art/spr_kid.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + dad: sprite("art/spr_dad.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + }, + world: { + grid: [ + "####################", + "####################", + "####################", + "####################", + "########.###...#####", + "########.###...#####", + "########.###...#####", + "######.........#####", + "...w...........#####", + "..........p....#####", + "....................", + "....................", + "..............######", + "..............######", + "dddd..........######", + ], + player: { actor: "kid", at: "p", dir: "up" }, + npcs: { + dad: { + actor: "dad", + at: "w", + dir: "right", + talk: cue(function* () { + if (yield hasFlag("bench")) { + yield dialog("DAD", "Done means the hidden\nparts are done too."); + } else { + yield dialog("DAD", "See this end of the\nbench? It's yours now."); + yield dialog("DAD", "Take things apart.\nLearn why they work."); + } + }), + }, + }, + spots: { + bench: { + at: [0, 4, 8, 4], + run: cue(function* () { + yield caption("sub", "His side is spotless.\nYours can be anything."); + yield waitA(); + yield captionClear("all"); + yield dialog("DAD", "Make the back of the\nfence as good as the"); + yield dialog("DAD", "front. Nobody sees it.\nYou will know."); + yield setFlag("bench"); + yield sfx("confirm"); + }), + }, + car: { + at: [15, 2, 5, 8], + run: cue(function* () { + yield caption("sub", "Dad rebuilds cars to\nresell. Every weekend."); + yield waitA(); + yield captionClear("all"); + }), + }, + }, + exits: { door: { at: "d", value: 1 } }, + }, + play: cue(function* () { + yield clrFlag("bench"); + yield letterbox(12, 1); + yield fadeIn(40); + yield caption("chip", "MOUNTAIN VIEW, EARLY 60s"); + yield letterbox(0, 30); + while (!(yield hasFlag("bench"))) { + yield world(); + if (!(yield hasFlag("bench"))) { + yield dialog("DAD", "Not yet. Come look at\nthe bench first."); + yield warp(2, 13, "up"); + } + } + yield captionClear("all"); + yield fadeOut(35); + }), +}); + +// --------------------------------------------------------------------------- +// 2 · THE CALL — Los Altos, 1968 (cine bridge) +// --------------------------------------------------------------------------- +const hewlett = defineScene({ + id: "hewlett", + main: image("art/bg_title.png"), + backdrop: "#0a0a14", + actors: { + phone: sprite("art/spr_phone.png", { w: 32, h: 32, at: [104, 96], screen: true }), + }, + play: cue(function* () { + yield letterbox(14, 1); + yield fadeIn(40); + yield caption("chip", "LOS ALTOS, 1968"); + yield wait(30); + yield caption("sub", "A frequency counter\nneeds parts. You are 12."); + yield waitA(); + yield captionClear("sub"); + yield show("phone"); + yield caption("sub", "The Palo Alto phone book\nlists Bill Hewlett."); + yield waitA(); + yield captionClear("all"); + const c = yield choice(["Dial it", "Put the book down"]); + if (c === 1) { + yield caption("sub", "You dial anyway."); + yield wait(40); + yield captionClear("all"); + } + yield sfx("blip"); + yield dialog("STEVE", "Hi. My name's Steve\nJobs. You don't know me,"); + yield dialog("STEVE", "but I'm 12 years old,\nand I'm building a"); + yield dialog("STEVE", "frequency counter, and\nI'd like some spare parts."); + yield wait(20); + yield caption("sub", "He laughs. You get\nthe parts."); + yield waitA(); + yield captionClear("all"); + yield caption("sub", "And a summer job at HP,\nbuilding counters."); + yield waitA(); + yield captionClear("all"); + yield hide("phone"); + yield caption("card", "ASK. THE WORST THEY\nCAN SAY IS NO."); + yield wait(90); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------- +// 3 · THE BLUE BOX — Berkeley, fall 1971 (cine) +// --------------------------------------------------------------------------- +const bluebox = defineScene({ + id: "bluebox", + main: image("art/bg_dorm.png"), + backdrop: "#0c0c16", + actors: { + hero: sprite("art/spr_hero.png", { w: 32, h: 32, frames: 12, walkFpd: 4, at: [58, 108] }), + woz: sprite("art/spr_woz.png", { w: 32, h: 32, frames: 3, walkFpd: 1, at: [160, 108] }), + box: sprite("art/spr_bluebox.png", { w: 32, h: 32, at: [110, 60], screen: true }), + }, + play: cue(function* () { + yield letterbox(14, 1); + yield fadeIn(40); + yield caption("chip", "BERKELEY, FALL 1971"); + yield letterbox(0, 25); + yield show("hero"); + yield show("woz", 150, 96, { flip: true }); + yield wait(20); + yield dialog("WOZ", "Esquire says the phone\nnetwork obeys whistles."); + yield dialog("WOZ", "We checked a book at\nSLAC. The tones are real."); + yield show("box"); + yield sfx("blip"); + yield wait(12); + yield sfx("blip"); + yield wait(12); + yield sfx("star"); + yield caption("sub", "Woz built it. All\ndigital. No adjustments."); + yield waitA(); + yield captionClear("all"); + yield dialog("WOZ", "I called the Vatican.\nSaid I was Kissinger."); + const c = yield choice(["Sell them", "Too risky"]); + if (c === 0) { + yield caption("sub", "Door to door in the\ndorms. $150 a box."); + } else { + yield caption("sub", "You sold them anyway.\n$150 a box."); + } + yield waitA(); + yield captionClear("all"); + yield shake(2, 30); + yield caption("sub", "One sale ended at\ngunpoint. You kept going."); + yield waitA(); + yield captionClear("all"); + yield caption("card", "NO BLUE BOXES,\nNO APPLE."); + yield caption("chip", "- HIM, LOOKING BACK"); + yield wait(110); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------- +// 4 · THE LETTERFORM — Reed College, 1972 (cine) +// --------------------------------------------------------------------------- +const reed = defineScene({ + id: "reed", + main: image("art/bg_reed.png"), + backdrop: "#181410", + play: cue(function* () { + yield letterbox(14, 1); + yield fadeIn(45); + yield caption("chip", "REED COLLEGE, 1972"); + yield wait(30); + yield caption("sub", "You dropped out after\nsix months. Then stayed."); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "Friends' floors. Coke\nbottles. A 7-mile walk"); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "for one good meal a\nweek. And one classroom."); + yield waitA(); + yield captionClear("all"); + yield letterbox(0, 25); + yield caption("chip", "THE CALLIGRAPHY ROOM"); + yield wait(20); + yield caption("card", "SERIF. SANS SERIF."); + yield wait(70); + yield captionClear("card"); + yield caption("sub", "What makes letters\nbeautiful. Palladino's"); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "class. None of it looked\nuseful for a living."); + yield waitA(); + yield captionClear("all"); + yield caption("card", "REMEMBER THIS ROOM."); + yield wait(90); + yield captionClear("all"); + yield mosaicTo(10, 40); + yield waitTweens(); + yield caption("chip", "THEN: INDIA, 7 MONTHS"); + yield mosaicTo(0, 30); + yield caption("sub", "The ashram stood empty.\nThe guru died last fall."); + yield waitA(); + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------- +// 5 · BREAKOUT — Atari, 1975, four nights (cine + minigame) +// --------------------------------------------------------------------------- +const atari = defineScene({ + id: "atari", + main: image("art/bg_atari.png"), + backdrop: "#0a0c14", + actors: { + woz: sprite("art/spr_woz.png", { w: 32, h: 32, frames: 3, walkFpd: 1, at: [40, 100] }), + }, + play: cue(function* () { + yield fadeIn(40); + yield caption("chip", "ATARI, 1975. NIGHT SHIFT"); + yield wait(30); + yield dialog("THE BOSS", "Breakout. Fewer chips,\nbigger bonus. Four days."); + yield show("woz"); + yield dialog("WOZ", "I'll design it. You\nwire and test. No sleep."); + yield captionClear("all"); + yield caption("sub", "Keep the prototype alive\nuntil dawn. A to launch."); + yield waitA(); + yield captionClear("all"); + const n = yield breakout(4, 3, 3600); + yield setVar("bricks", n); + if (yield varGe("bricks", 40)) { + yield caption("sub", "The wall came down\nbefore the sun came up."); + } else { + yield caption("sub", "Dawn came first. The\ndesign held anyway."); + } + yield waitA(); + yield captionClear("all"); + yield caption("sub", "44-46 chips. So tight\nAtari couldn't build it."); + yield waitA(); + yield captionClear("all"); + yield letterbox(14, 25); + yield caption("sub", "Woz got $350 of what\nyou called $700."); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "A book later said there\nwas a bonus. He denied it."); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "Woz cried anyway. And\nsaid he'd do it for free."); + yield waitA(); + yield captionClear("all"); + yield fadeOut(45); + }), +}); + +// --------------------------------------------------------------------------- +// 6 · FIFTY BOARDS — Los Altos garage, April 1976 (world + encounter) +// --------------------------------------------------------------------------- +const garage76 = defineScene({ + id: "garage76", + main: image("art/map_garage76.png"), + backdrop: "#141018", + actors: { + hero: sprite("art/spr_hero.png", { w: 32, h: 32, frames: 12, walkFpd: 4 }), + woz: sprite("art/spr_woz.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + supplier: sprite("art/port_supplier.png", { w: 64, h: 64, screen: true }), + }, + world: { + grid: [ + "####################", + "####################", + "####################", + "####################", + "####################", + "########ddd#########", + ".................###", + "#..w...............#", + "#.######.....#######", + "#.######.....#######", + "..######..p..#######", + "########.....#######", + "########.....#######", + "##...............###", + "....................", + ], + player: { actor: "hero", at: "p", dir: "up" }, + npcs: { + woz: { + actor: "woz", + at: "w", + dir: "down", + talk: cue(function* () { + if (yield hasFlag("woz76")) { + yield dialog("WOZ", "Every single board\nworks. Every one."); + } else { + yield dialog("WOZ", "I design at my HP desk.\nHere we just test."); + yield dialog("WOZ", "Fifty boards in thirty\ndays. We can do this."); + yield setFlag("woz76"); + } + }), + }, + }, + spots: { + tableL: { + at: [2, 8, 6, 5], + run: cue(function* () { + yield caption("sub", "Patti and Dan solder.\nA dollar a board."); + yield waitA(); + yield captionClear("all"); + }), + }, + tableR: { + at: [13, 8, 5, 5], + run: cue(function* () { + yield caption("sub", "Burned-in, tested,\nstacked. Day by day."); + yield waitA(); + yield captionClear("all"); + }), + }, + paper: { + at: [2, 2, 2, 4], + run: cue(function* () { + yield caption("sub", "The partnership paper.\nApril 1. Woz, you, Wayne."); + yield waitA(); + yield captionClear("all"); + }), + }, + phone: { + at: [7, 3, 1, 3], + run: cue(function* () { + if (yield varEq("credit", 1)) { + yield dialog("SUPPLIER", "Thirty days, kid.\nDon't be late."); + return; + } + yield caption("sub", "The parts man again.\nHe wants cash up front."); + yield waitA(); + yield captionClear("all"); + yield show("supplier", 168, 24); + yield setVar("trust", 2); + yield meterShow(0, "trust", 16, 32, 8); + while (yield varLt("trust", 8)) { + const m = yield choice(["Mention the order", "Promise net thirty", "Talk faster"]); + if (m === 0) { + yield dialog("YOU", "Fifty boards for the\nByte Shop. Cash on"); + yield dialog("YOU", "delivery. Call Paul\nTerrell. He'll confirm."); + yield sfx("confirm"); + yield addVar("trust", 3); + } + if (m === 1) { + yield dialog("YOU", "Parts now, paid in 30\ndays. We ship in 29."); + yield sfx("blip"); + yield addVar("trust", 2); + } + if (m === 2) { + yield dialog("SUPPLIER", "Slow down. Talking\nfaster isn't collateral."); + yield addVar("trust", -1); + } + } + yield shake(2, 20); + yield dialog("SUPPLIER", "...Net thirty. If Terrell\nvouches, you get parts."); + yield meterHide(0); + yield hide("supplier"); + yield setVar("credit", 1); + yield sfx("star"); + yield caption("sub", "Parts on credit. The\nclock starts now."); + yield waitA(); + yield captionClear("all"); + }), + }, + }, + exits: { door: { at: "d", value: 1 } }, + }, + play: cue(function* () { + yield setVar("credit", 0); + yield clrFlag("woz76"); + yield letterbox(12, 1); + yield fadeIn(40); + yield caption("chip", "LOS ALTOS, APRIL 1976"); + yield letterbox(0, 25); + yield caption("sub", "Yesterday, barefoot, you\nwalked into the Byte Shop."); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "Terrell: 50 assembled,\ntested boards. $500 each,"); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "cash on delivery. You\nhave no parts. No money."); + yield waitA(); + yield captionClear("all"); + while (yield varEq("credit", 0)) { + yield world(); + if (yield varEq("credit", 0)) { + yield caption("sub", "Not yet. No parts, no\ndelivery. Try the phone."); + yield waitA(); + yield captionClear("all"); + yield warp(9, 6, "down"); + } + } + yield captionClear("all"); + yield caption("card", "DAY 29: DELIVERED."); + yield wait(80); + yield captionClear("all"); + yield caption("sub", "Apple I. $666.66 retail.\nAbout 175 ever sold."); + yield waitA(); + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------- +// 7 · THE FAIRE — San Francisco, April 1977 (cine bridge) +// --------------------------------------------------------------------------- +const faire = defineScene({ + id: "faire", + main: image("art/bg_faire.png"), + backdrop: "#101018", + play: cue(function* () { + yield fadeIn(40); + yield caption("chip", "SAN FRANCISCO, 1977"); + yield wait(30); + yield caption("sub", "The booth faces the\nentrance. On purpose."); + yield waitA(); + yield captionClear("sub"); + yield caption("card", "APPLE II"); + yield wait(70); + yield captionClear("card"); + yield caption("sub", "Three finished units.\nBehind them, a bluff of"); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "empty cases. $1,298 in\na home-friendly shell."); + yield waitA(); + yield captionClear("all"); + yield letterbox(14, 25); + yield caption("chip", "DEC 12, 1980: THE IPO"); + yield caption("sub", "Around 300 people became\nmillionaires that day."); + yield waitA(); + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------- +// 8 · THE GOLDMINE — Xerox PARC, December 1979 (world) +// --------------------------------------------------------------------------- +const parc = defineScene({ + id: "parc", + main: image("art/map_parc.png"), + backdrop: "#181418", + actors: { + hero: sprite("art/spr_hero.png", { w: 32, h: 32, frames: 12, walkFpd: 4 }), + res: sprite("art/spr_res.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + }, + world: { + grid: [ + "####################", + "####################", + "####################", + "####################", + "###.....####ddd..###", + "###.....##.......###", + "###..r...........###", + "..................#.", + ".................##.", + "##...............##.", + "##..................", + "##..................", + "##..................", + "##................##", + "##..................", + ], + player: { actor: "hero", at: [10, 11], dir: "up" }, + npcs: { + res: { + actor: "res", + at: "r", + dir: "down", + talk: cue(function* () { + if (yield hasFlag("res79")) { + yield dialog("RESEARCHER", "The suits upstairs have\nno idea what this is."); + } else { + yield dialog("RESEARCHER", "Corporate sold you this\ndemo. A million dollars"); + yield dialog("RESEARCHER", "of your pre-IPO shares\nfor a look. A look!"); + yield setFlag("res79"); + } + }), + }, + }, + spots: { + alto: { + at: [8, 2, 4, 4], + run: cue(function* () { + yield caption("sub", "A white screen. Windows.\nMenus. A little arrow"); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "that follows your hand\nacross the desk."); + yield waitA(); + yield captionClear("all"); + yield shake(2, 25); + yield dialog("YOU", "You're sitting on a\ngold mine! Why aren't"); + yield dialog("YOU", "you doing something\nwith this technology?"); + yield setFlag("alto"); + yield sfx("star"); + }), + }, + }, + autos: { + blinded: { + at: [8, 8, 4, 2], + run: cue(function* () { + yield caption("sub", "They showed you three\nthings that day."); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "You were so blinded by\nthe first, you missed two."); + yield waitA(); + yield captionClear("all"); + }), + }, + }, + exits: { door: { at: "d", value: 1 } }, + }, + play: cue(function* () { + yield clrFlag("res79"); + yield clrFlag("alto"); + yield letterbox(12, 1); + yield fadeIn(40); + yield caption("chip", "XEROX PARC, DEC 1979"); + yield letterbox(0, 25); + while (!(yield hasFlag("alto"))) { + yield world(); + if (!(yield hasFlag("alto"))) { + yield caption("sub", "Not yet. See the machine\nby the bookshelves first."); + yield waitA(); + yield captionClear("all"); + yield warp(13, 5, "left"); + } + } + yield captionClear("all"); + yield caption("sub", "Ten minutes in, you knew\nhow all computers would"); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "work, someday. Xerox\nnever did sell it."); + yield waitA(); + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------- +// 9 · SUGARED WATER — San Remo terrace, March 1983 (encounter) +// --------------------------------------------------------------------------- +const sculley = defineScene({ + id: "sculley", + main: image("art/bg_penthouse.png"), + backdrop: "#181020", + actors: { + sc: sprite("art/port_sculley.png", { w: 64, h: 64, screen: true }), + me: sprite("art/port_hero.png", { w: 64, h: 64, screen: true }), + }, + play: cue(function* () { + yield letterbox(14, 1); + yield fadeIn(45); + yield caption("chip", "SAN REMO TERRACE, 1983"); + yield wait(30); + yield caption("sub", "The penthouse you're\nbuying. He runs Pepsi."); + yield waitA(); + yield captionClear("all"); + yield show("sc", 168, 28); + yield show("me", 8, 28); + yield dialog("SCULLEY", "Pepsi is a good life,\nSteve. Why would I go?"); + yield setVar("conv", 2); + yield meterShow(0, "conv", 88, 34, 8); + while (yield varLt("conv", 8)) { + const m = yield choice(["Talk numbers", "Paint the future", "The question"]); + if (m === 0) { + yield dialog("YOU", "Apple will do a billion\nin revenue by--"); + yield dialog("SCULLEY", "I run a company twice\nthat size. Next."); + yield addVar("conv", -1); + } + if (m === 1) { + if (yield varLe("conv", 3)) { + yield dialog("YOU", "Computers will be\nbicycles for the mind."); + yield sfx("blip"); + } else { + yield dialog("YOU", "We're not selling boxes.\nWe're bending the curve"); + yield dialog("YOU", "of what a person\ncan do alone."); + yield sfx("blip"); + } + yield addVar("conv", 2); + } + if (m === 2) { + if (yield varLt("conv", 6)) { + yield dialog("SCULLEY", "Ask me when you mean\nit, Steve."); + } else { + yield shake(2, 30); + yield dialog("YOU", "Do you want to spend\nthe rest of your life"); + yield dialog("YOU", "selling sugared water,\nor do you want a chance"); + yield dialog("YOU", "to change the world?"); + yield setVar("conv", 8); + } + } + } + yield sfx("star"); + yield wait(20); + yield dialog("SCULLEY", "..."); + yield dialog("SCULLEY", "You're dangerous,\nSteve Jobs."); + yield meterHide(0); + yield caption("chip", "HE SAID YES IN APRIL."); + yield wait(90); + yield captionClear("all"); + yield fadeOut(45, "white"); + }), +}); + +// --------------------------------------------------------------------------- +// 10 · PIRATES — Bandley 3, August 1983 (world) +// --------------------------------------------------------------------------- +const bandley = defineScene({ + id: "bandley", + main: image("art/map_bandley.png"), + backdrop: "#101420", + actors: { + hero: sprite("art/spr_hero.png", { w: 32, h: 32, frames: 12, walkFpd: 4 }), + team: sprite("art/spr_team.png", { w: 32, h: 32, frames: 3, walkFpd: 1 }), + flag: sprite("art/spr_flag.png", { w: 32, h: 32 }), + mac: sprite("art/spr_mac.png", { w: 32, h: 32 }), + }, + world: { + grid: [ + "####################", + "####################", + "####################", + "####################", + "####.#ddd#####.#####", + "####.....####..#####", + "####...........#####", + "####...........#####", + "...##...t.......####", + ".####..........#####", + ".####............###", + ".####...............", + "....................", + "#..###.......###...#", + "#..###.......###...#", + ], + player: { actor: "hero", at: [10, 10], dir: "up" }, + npcs: { + team: { + actor: "team", + at: "t", + dir: "down", + talk: cue(function* () { + if (yield hasFlag("team83")) { + yield dialog("ENGINEER", "The Lisa folks will\nsteal that flag someday."); + } else { + yield dialog("ENGINEER", "90 hours a week and\nloving it. Mostly."); + yield dialog("ENGINEER", "Ship date is January\n24th. Nobody sleeps."); + yield setFlag("team83"); + } + }), + }, + }, + spots: { + flagpole: { + at: [16, 3, 3, 3], + run: cue(function* () { + yield caption("sub", "Capps sewed the flag.\nKare painted the skull."); + yield waitA(); + yield captionClear("all"); + yield caption("card", "BETTER TO BE A PIRATE\nTHAN JOIN THE NAVY"); + yield wait(90); + yield captionClear("all"); + yield setFlag("flag83"); + yield sfx("confirm"); + }), + }, + macdesk: { + at: [15, 6, 4, 4], + run: cue(function* () { + yield caption("sub", "The prototype. Nine-inch\nscreen. It says hello."); + yield waitA(); + yield captionClear("all"); + yield dialog("YOU", "The Apple II is the\npast. This is the future."); + yield setFlag("mac83"); + }), + }, + }, + exits: { door: { at: "d", value: 1 } }, + }, + play: cue(function* () { + yield clrFlag("team83"); + yield clrFlag("flag83"); + yield clrFlag("mac83"); + yield letterbox(12, 1); + yield fadeIn(40); + yield caption("chip", "BANDLEY 3, AUGUST 1983"); + yield letterbox(0, 25); + yield show("flag", 272, 40); + while (!(yield hasFlag("mac83"))) { + yield world(); + if (!(yield hasFlag("mac83"))) { + yield caption("sub", "Not yet. Look at the\nprototype on the desk."); + yield waitA(); + yield captionClear("all"); + yield warp(7, 5, "down"); + } + } + yield captionClear("all"); + yield fadeOut(40); + }), +}); + +// --------------------------------------------------------------------------- +// 11 · HELLO — Flint Center, January 24, 1984 (cine finale) +// --------------------------------------------------------------------------- +const keynote = defineScene({ + id: "keynote", + main: image("art/bg_stage84.png"), + backdrop: "#06070c", + actors: { + mac: sprite("art/spr_mac.png", { w: 32, h: 32, at: [112, 64], screen: true }), + }, + play: cue(function* () { + yield letterbox(16, 1); + yield fadeIn(50); + yield caption("chip", "FLINT CENTER, 1984"); + yield wait(30); + yield caption("sub", "January 24. The annual\nshareholders meeting."); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "Black suit. Bow tie.\nYou recite Dylan."); + yield waitA(); + yield captionClear("sub"); + yield caption("sub", "The Super Bowl ad plays\none more time. Press A!"); + yield setVar("claps", 0); + yield counter("claps", 208, 24); + yield mash("claps", 15); + yield counterHide(); + yield captionClear("all"); + yield sfx("whoosh"); + yield letterbox(0, 30); + yield caption("sub", "You pull it out of a\ncanvas bag. Insert floppy."); + yield waitA(); + yield captionClear("all"); + yield show("mac"); + yield affineOn("mac"); + yield zoom(0.3, 1); + yield zoom(1.0, 70, "out"); + yield waitTweens(); + yield caption("sub", "Chariots of Fire swells.\n(A 512K prototype. Shh.)"); + yield waitA(); + yield captionClear("all"); + yield wait(20); + yield dialog("MACINTOSH", "Hello, I am Macintosh.\nIt sure is great to get"); + yield dialog("MACINTOSH", "out of that bag!"); + yield dialog("MACINTOSH", "Never trust a computer\nthat you can't lift!"); + yield shake(2, 30); + yield dialog("MACINTOSH", "It is with considerable\npride that I introduce"); + yield dialog("MACINTOSH", "a man who's been like\na father to me:"); + yield dialog("MACINTOSH", "Steve Jobs!"); + yield sfx("star"); + yield shake(3, 60); + yield caption("sub", "Five minutes of thunder.\nThe whole room, standing."); + yield waitA(); + yield captionClear("all"); + yield caption("sub", "You are 28. You hold\nback tears. Barely."); + yield waitA(); + yield captionClear("all"); + yield zoom(1.3, 120, "inout"); + yield fadeOut(70, "white"); + }), +}); + +// --------------------------------------------------------------------------- +// 12 · CREDITS (cine) +// --------------------------------------------------------------------------- +const credits = defineScene({ + id: "credits", + main: image("art/bg_orchard.png"), + backdrop: "#101018", + play: cue(function* () { + yield fadeIn(60, "white"); + yield caption("card", "REALITY DISTORTION"); + yield caption("chip", "PART ONE, 1955-1984"); + yield wait(140); + yield captionClear("all"); + yield caption("sub", "Documented lines appear\nas recorded. The rest is"); + yield wait(120); + yield captionClear("sub"); + yield caption("sub", "original fan writing.\nSources: game/dossier.md"); + yield wait(120); + yield captionClear("all"); + yield caption("sub", "A tribute. Not affiliated\nwith or endorsed."); + yield wait(120); + yield captionClear("all"); + yield caption("card", "PART TWO:\nTHE WILDERNESS"); + yield wait(140); + yield captionClear("all"); + yield caption("sub", "@pocketjs/saga engine.\nPixel art via PixelLab."); + yield wait(120); + yield captionClear("all"); + yield fadeOut(50); + yield gotoScene("title"); + }), +}); + +export default defineFilm({ + title: "REALITY DISTORTION", + scenes: [title, garage62, hewlett, bluebox, reed, atari, garage76, faire, parc, sculley, bandley, keynote, credits], +}); diff --git a/saga/package.json b/saga/package.json new file mode 100644 index 0000000..e70923a --- /dev/null +++ b/saga/package.json @@ -0,0 +1,24 @@ +{ + "name": "@pocketjs/saga", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "GBA interactive-biography DSL: cine's whole cinematic vocabulary (Mode-0 parallax, raster FX, typewriter captions) plus top-down walkable world scenes, NPC/trigger cues, encounter meters and a Breakout set piece — one TypeScript source, one flashcart-ready ROM.", + "exports": { + ".": "./dsl/index.ts", + "./compiler": "./compiler/index.ts", + "./spec": "./spec/saga.ts" + }, + "scripts": { + "gen": "bun spec/gen-c.ts", + "build": "bun compiler/cli.ts build game/reality-distortion.ts --out dist/reality-distortion.gba --title REALDISTORT", + "smoke": "bun test/gen-placeholder-art.ts && bun compiler/cli.ts build test/smoke-film.ts --out dist/smoke.gba --title SAGASMOKE", + "test:engine": "bun test/engine-e2e.ts", + "art": "bun pixellab/generate.ts", + "test": "bun test/e2e.ts", + "play": "bash play.sh" + }, + "devDependencies": { + "typescript": "^5" + } +} diff --git a/saga/pixellab/client.ts b/saga/pixellab/client.ts new file mode 100644 index 0000000..a8c0451 --- /dev/null +++ b/saga/pixellab/client.ts @@ -0,0 +1,77 @@ +// saga/pixellab/client.ts — thin typed client for the PixelLab API +// (https://api.pixellab.ai/v1). Bearer key comes from the repo-root .env +// (PIXELLAB_API_KEY). Endpoints used: generate-image-pixflux (up to 400x400), +// with retry/backoff; responses carry base64 PNGs. + +const API = "https://api.pixellab.ai/v1"; + +let cachedKey: string | null = null; + +export function apiKey(): string { + if (cachedKey) return cachedKey; + const envPath = new URL("../../.env", import.meta.url).pathname; + const text = require("node:fs").readFileSync(envPath, "utf8") as string; + const m = text.match(/^PIXELLAB_API_KEY=(.+)$/m); + if (!m) throw new Error("PIXELLAB_API_KEY not found in repo .env"); + cachedKey = m[1].trim(); + return cachedKey; +} + +export interface PixfluxOpts { + description: string; + width: number; + height: number; + negative?: string; + noBackground?: boolean; + outline?: "single color black outline" | "single color outline" | "selective outline" | "lineless"; + shading?: "flat shading" | "basic shading" | "medium shading" | "detailed shading" | "highly detailed shading"; + detail?: "low detail" | "medium detail" | "highly detailed"; + view?: "side" | "low top-down" | "high top-down"; + direction?: string; + textGuidance?: number; + seed?: number; + /** force palette: PNG bytes whose colors constrain the output */ + colorImage?: Uint8Array; +} + +async function post(path: string, body: unknown): Promise> { + let lastErr = ""; + for (let attempt = 0; attempt < 4; attempt++) { + const res = await fetch(API + path, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey()}`, "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (res.ok) return (await res.json()) as Record; + lastErr = `${res.status} ${await res.text()}`; + if (res.status === 422 || res.status === 401) break; // no point retrying + await new Promise((r) => setTimeout(r, 2000 * (attempt + 1))); + } + throw new Error(`pixellab ${path}: ${lastErr}`); +} + +export async function pixflux(opts: PixfluxOpts): Promise { + const body: Record = { + description: opts.description, + image_size: { width: opts.width, height: opts.height }, + no_background: opts.noBackground ?? false, + text_guidance_scale: opts.textGuidance ?? 8, + }; + if (opts.negative) body.negative_description = opts.negative; + if (opts.outline) body.outline = opts.outline; + if (opts.shading) body.shading = opts.shading; + if (opts.detail) body.detail = opts.detail; + if (opts.view) body.view = opts.view; + if (opts.direction) body.direction = opts.direction; + if (opts.seed !== undefined) body.seed = opts.seed; + if (opts.colorImage) body.color_image = { type: "base64", base64: Buffer.from(opts.colorImage).toString("base64") }; + const res = await post("/generate-image-pixflux", body); + const img = res.image as { base64: string } | undefined; + if (!img?.base64) throw new Error("pixellab: no image in response"); + return new Uint8Array(Buffer.from(img.base64, "base64")); +} + +export async function balance(): Promise { + const res = await fetch(API + "/balance", { headers: { Authorization: `Bearer ${apiKey()}` } }); + return await res.text(); +} diff --git a/saga/pixellab/generate.ts b/saga/pixellab/generate.ts new file mode 100644 index 0000000..ff181bd --- /dev/null +++ b/saga/pixellab/generate.ts @@ -0,0 +1,366 @@ +// saga/pixellab/generate.ts — generate every game asset through PixelLab and +// cache it under game/art/ (committed, so builds never re-bill). +// bun pixellab/generate.ts [--force] [--only name[,name]] +// +// Then `bun pixellab/walkers.ts` turns the *_s/_n/_e stills into walker sheets +// (hero gets real 4-frame walk cycles via /animate-with-text). +// +// Prompts are deliberately franchise-neutral: machines are "vintage +// computers", no logos, no trade dress. This is an original fan tribute. + +import { pixflux, balance, type PixfluxOpts } from "./client.ts"; + +const OUT = new URL("../game/art/", import.meta.url).pathname; + +interface Spec extends Omit { + name: string; + w: number; + h: number; +} + +const PIXEL_STYLE = "clean 16-bit pixel art, limited palette, crisp pixels"; +const MAP_STYLE = + "seen directly from above like a Game Boy RPG town map, clean 16x16 tile alignment, top-down 2D RPG interior map"; +const SPRITE_STYLE = "tiny pixel art RPG overworld sprite, full body head to toe, Game Boy Advance RPG overworld style"; + +const HERO = "young man in his early twenties, shoulder-length dark brown hair, short beard, white collared shirt, blue jeans, brown sandals"; +const KID = "twelve year old boy, short dark hair, orange striped t-shirt, blue shorts, sneakers"; +const DAD = "man in his fifties, short crew cut hair, plaid work shirt, gray trousers"; +const WOZ = "stocky young man, dark shaggy hair, full beard, square glasses, dark green shirt, jeans"; +const RESEARCHER = "man in his thirties, brown mustache, light blue button-up shirt, dark slacks"; +const TEAMMATE = "young woman, dark bobbed 80s hair, red sweater, dark skirt"; + +export const SPECS: Spec[] = [ + // --- world maps (320x240 = 20x15 cells) -------------------------------------- + { + name: "map_garage68", + w: 320, + h: 240, + description: + `top-down 2D RPG interior map of a 1960s American suburban garage, ${MAP_STYLE}, brown wooden plank walls forming the room border, smooth gray concrete floor, a long wooden workbench with hand tools and a vise along the top wall, a pegboard, an old sedan car parked on the right half, cardboard boxes and an oil can in a corner, a wall telephone near the top left door`, + negative: "people, person, human, text, side view, perspective, isometric", + view: "high top-down", + detail: "highly detailed", + shading: "basic shading", + seed: 62010, + }, + { + name: "map_garage76", + w: 320, + h: 240, + description: + `top-down 2D RPG interior map of a 1970s American garage turned into a tiny electronics workshop, ${MAP_STYLE}, brown wooden plank walls forming the room border, gray concrete floor, two long assembly tables covered with bare green circuit boards and soldering irons, a burn-in rack of boards along one wall, stacked cardboard shipping boxes, a wall telephone near the top left door, warm work lamps`, + negative: "people, person, human, car, text, side view, perspective, isometric", + view: "high top-down", + detail: "highly detailed", + shading: "basic shading", + seed: 62011, + }, + { + name: "map_parc", + w: 320, + h: 240, + description: + `top-down 2D RPG interior map of a 1970s corporate research laboratory, ${MAP_STYLE}, clean beige walls, light gray carpet floor, several white desks in an open plan, on one central desk a tall white computer with a vertical portrait monitor and a small box mouse on a pad, bookshelves along the top wall, a beanbag corner, potted plants`, + negative: "people, person, human, text, side view, perspective, isometric", + view: "high top-down", + detail: "highly detailed", + shading: "basic shading", + seed: 62012, + }, + { + name: "map_bandley", + w: 320, + h: 240, + description: + `top-down 2D RPG interior map of an early 1980s open-plan software office, ${MAP_STYLE}, white walls, blue-gray carpet, desks with small beige computers and keyboards, a whiteboard on the top wall, a couch and coffee table corner with a rubber plant, pizza boxes on one desk, a tall flag pole in one corner`, + negative: "people, person, human, text, side view, perspective, isometric", + view: "high top-down", + detail: "highly detailed", + shading: "basic shading", + seed: 62013, + }, + + // --- cinematic backgrounds (240x160, side view) -------------------------------- + { + name: "bg_title", + w: 240, + h: 160, + description: + `quiet California suburban street at dusk, side view: a modest ranch house with its garage door half open and glowing warm from inside, silhouetted fruit trees, purple-orange sky, first stars, ${PIXEL_STYLE}`, + negative: "people, text, letters, logo", + view: "side", + shading: "detailed shading", + detail: "highly detailed", + seed: 62020, + }, + { + name: "bg_dorm", + w: 240, + h: 160, + description: + `1971 college dorm room at night, side view: a narrow bed, a wooden desk crowded with electronic parts, a soldering iron, loose wires and a small blue metal box, one desk lamp, a rotary phone on the wall, posters, ${PIXEL_STYLE}`, + negative: "people, text, letters", + view: "side", + shading: "medium shading", + detail: "highly detailed", + seed: 62021, + }, + { + name: "bg_reed", + w: 240, + h: 160, + description: + `sunlit college calligraphy classroom, side view: tall windows with dust motes, wooden drafting desks with ink pots and wide paper sheets showing flowing black pen strokes, a blackboard with elegant swash strokes drawn in chalk, warm morning light, ${PIXEL_STYLE}`, + negative: "people, readable words, letters, text", + view: "side", + shading: "medium shading", + detail: "highly detailed", + seed: 62022, + }, + { + name: "bg_atari", + w: 240, + h: 160, + description: + `1970s video game company workshop at night, side view: a row of dark arcade cabinets along the back wall, a workbench with an oscilloscope and circuit boards, one bright desk lamp pool of light, dark blue shadows, ${PIXEL_STYLE}`, + negative: "people, text, letters, screens with images", + view: "side", + shading: "medium shading", + detail: "highly detailed", + seed: 62023, + }, + { + name: "bg_faire", + w: 240, + h: 160, + description: + `1977 computer trade show hall, side view: a convention booth with a clean beige home computer with integrated keyboard on a draped table, colorful pennant banners overhead, crowd barriers, bright show lighting, ${PIXEL_STYLE}`, + negative: "people, text, letters, logo", + view: "side", + shading: "medium shading", + detail: "highly detailed", + seed: 62024, + }, + { + name: "bg_penthouse", + w: 240, + h: 160, + description: + `Manhattan penthouse balcony at dusk, side view: stone balustrade in the foreground, vast city skyline with lit windows below, hazy orange-to-blue gradient sky, two empty chairs and a small table with glasses, ${PIXEL_STYLE}`, + negative: "people, text, letters", + view: "side", + shading: "detailed shading", + detail: "highly detailed", + seed: 62025, + }, + { + name: "bg_stage84", + w: 240, + h: 160, + description: + `dark auditorium stage, side view: one strong spotlight cone on a small table at center stage with a canvas bag on it, huge dark projection screen behind, front rows of audience as black silhouettes, deep blue darkness, ${PIXEL_STYLE}`, + negative: "text, letters, faces", + view: "side", + shading: "detailed shading", + detail: "highly detailed", + seed: 62026, + }, + { + name: "bg_orchard", + w: 240, + h: 160, + description: + `rolling green orchard hills at sunrise, side view: rows of small fruit trees on soft hills, morning mist in the valley, pale gold sky with a rising sun, one dirt path, ${PIXEL_STYLE}`, + negative: "people, text, letters", + view: "side", + shading: "detailed shading", + detail: "highly detailed", + seed: 62027, + }, + + // --- walker stills (32x32; sheets assembled by pixellab/walkers.ts) ----------- + ...( + [ + ["hero", HERO, 62030], + ["kid", KID, 62233], + ["dad", DAD, 62036], + ["woz", WOZ, 62039], + ["res", RESEARCHER, 62042], + ["team", TEAMMATE, 62045], + ] as const + ).flatMap(([who, look, seed]): Spec[] => [ + { + name: `walk_${who}_s`, + w: 32, + h: 32, + description: `${SPRITE_STYLE} of a ${look}, standing still, arms at sides, facing the viewer`, + negative: "portrait, bust, cropped, text", + view: "low top-down", + direction: "south", + noBackground: true, + outline: "single color black outline", + shading: "flat shading", + seed, + }, + { + name: `walk_${who}_n`, + w: 32, + h: 32, + description: `${SPRITE_STYLE} of a ${look}, standing still, seen from behind`, + negative: "portrait, bust, cropped, text, face", + view: "low top-down", + direction: "north", + noBackground: true, + outline: "single color black outline", + shading: "flat shading", + seed: seed + 1, + }, + { + name: `walk_${who}_e`, + w: 32, + h: 32, + description: `${SPRITE_STYLE} of a ${look}, standing still, side profile facing right`, + negative: "portrait, bust, cropped, text", + view: "low top-down", + direction: "east", + noBackground: true, + outline: "single color black outline", + shading: "flat shading", + seed: seed + 2, + }, + ]), + + // --- encounter portraits (64x64) ------------------------------------------------ + { + name: "port_sculley", + w: 64, + h: 64, + description: + `pixel art bust portrait of a confident American executive in his mid forties, neat side-parted light brown hair, navy suit, striped tie, slight guarded smile, plain dark background, ${PIXEL_STYLE}`, + negative: "text, letters, full body", + shading: "detailed shading", + detail: "highly detailed", + seed: 62050, + }, + { + name: "port_supplier", + w: 64, + h: 64, + description: + `pixel art bust portrait of a skeptical older parts salesman, balding with gray temples, thick glasses, short-sleeve white shirt with a pen in the pocket, plain dark background, ${PIXEL_STYLE}`, + negative: "text, letters, full body", + shading: "detailed shading", + detail: "highly detailed", + seed: 62051, + }, + { + name: "port_hero", + w: 64, + h: 64, + description: + `pixel art bust portrait of a ${HERO}, intense dark eyes, faint smile, plain dark background, ${PIXEL_STYLE}`, + negative: "text, letters, full body", + shading: "detailed shading", + detail: "highly detailed", + seed: 62052, + }, + + // --- props (OBJ sprites) -------------------------------------------------------- + { + name: "spr_bluebox", + w: 32, + h: 32, + description: `one small handheld blue metal electronic box with a white keypad of round buttons, top-down slight angle, transparent background, ${PIXEL_STYLE}`, + negative: "text, letters", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + seed: 62060, + }, + { + name: "spr_board", + w: 32, + h: 32, + description: `one bare green computer circuit board with rows of black chips and golden traces, slight angle, transparent background, ${PIXEL_STYLE}`, + negative: "text, letters", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + seed: 62061, + }, + { + name: "spr_alto", + w: 32, + h: 32, + description: `one 1970s white research computer with a tall vertical portrait monitor and a small keyboard, front view, transparent background, ${PIXEL_STYLE}`, + negative: "text, letters", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + seed: 62062, + }, + { + name: "spr_mac", + w: 32, + h: 32, + description: `one small friendly beige all-in-one computer, compact vertical case with a small built-in screen glowing soft white and a detached keyboard in front, front view, transparent background, ${PIXEL_STYLE}`, + negative: "text, letters, logo", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + seed: 62063, + }, + { + name: "spr_flag", + w: 32, + h: 32, + description: `one black pirate flag with a white skull, waving on a short pole, transparent background, ${PIXEL_STYLE}`, + negative: "text, letters, arrow, sign", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + seed: 62064, + }, + { + name: "spr_phone", + w: 32, + h: 32, + description: `one 1960s black rotary desk telephone with a coiled cord, slight angle, transparent background, ${PIXEL_STYLE}`, + negative: "text, letters", + noBackground: true, + outline: "single color black outline", + shading: "basic shading", + seed: 62065, + }, +]; + +if (import.meta.main) { + const force = process.argv.includes("--force"); + const onlyArg = process.argv.indexOf("--only"); + const only = onlyArg >= 0 ? new Set(process.argv[onlyArg + 1].split(",")) : null; + + console.log("pixellab balance:", await balance()); + const manifest: Record = {}; + const manifestPath = OUT + "manifest.json"; + try { + Object.assign(manifest, JSON.parse(await Bun.file(manifestPath).text())); + } catch {} + + for (const spec of SPECS) { + const { name, w, h, ...opts } = spec; + if (only && !only.has(name)) continue; + const out = OUT + name + ".png"; + if (!force && (await Bun.file(out).exists())) { + console.log(` skip ${name} (cached)`); + continue; + } + process.stdout.write(` gen ${name} ${w}x${h}... `); + const png = await pixflux({ ...opts, width: w, height: h }); + await Bun.write(out, png); + manifest[name] = { prompt: spec.description, seed: spec.seed, w, h }; + await Bun.write(manifestPath, JSON.stringify(manifest, null, 2)); + console.log(`ok (${png.length}B)`); + } + console.log("done. balance:", await balance()); +} diff --git a/saga/pixellab/walkers.ts b/saga/pixellab/walkers.ts new file mode 100644 index 0000000..b3a644b --- /dev/null +++ b/saga/pixellab/walkers.ts @@ -0,0 +1,123 @@ +// saga/pixellab/walkers.ts — assemble walker sheets from the generated stills. +// bun pixellab/walkers.ts [--force] +// +// hero: real 4-frame walk cycles per direction via /animate-with-text +// (64x64 minimum -> 2x nearest-neighbor round trip) -> spr_hero.png +// 384x32, rows DOWN,UP,SIDE x 4 frames (walkFpd 4). Frame 0 of each +// row is the standing still. +// npcs: 3-frame sheets [south, north, east] (walkFpd 1) -> spr_.png. + +import { apiKey } from "./client.ts"; +import { decodePng, encodePng } from "../compiler/png.ts"; + +const ART = new URL("../game/art/", import.meta.url).pathname; +const FRAME = 32; + +function nn(rgba: Uint8Array, w: number, h: number, k: number): Uint8Array { + const out = new Uint8Array(w * k * h * k * 4); + for (let y = 0; y < h * k; y++) + for (let x = 0; x < w * k; x++) { + const si = (Math.floor(y / k) * w + Math.floor(x / k)) * 4; + out.set(rgba.subarray(si, si + 4), (y * w * k + x) * 4); + } + return out; +} +function shrink2(rgba: Uint8Array, w: number, h: number): Uint8Array { + const out = new Uint8Array((w / 2) * (h / 2) * 4); + for (let y = 0; y < h / 2; y++) + for (let x = 0; x < w / 2; x++) { + out.set(rgba.subarray((y * 2 * w + x * 2) * 4, (y * 2 * w + x * 2) * 4 + 4), (y * (w / 2) + x) * 4); + } + return out; +} + +async function loadFrame(path: string): Promise { + const d = decodePng(new Uint8Array(await Bun.file(path).arrayBuffer())); + if (d.width !== FRAME || d.height !== FRAME) throw new Error(`${path}: expected ${FRAME}x${FRAME}`); + return d.rgba; +} + +function sheet(frames: Uint8Array[]): Uint8Array { + const w = FRAME * frames.length; + const out = new Uint8Array(w * FRAME * 4); + frames.forEach((f, i) => { + for (let y = 0; y < FRAME; y++) + for (let x = 0; x < FRAME; x++) { + out.set(f.subarray((y * FRAME + x) * 4, (y * FRAME + x) * 4 + 4), (y * w + i * FRAME + x) * 4); + } + }); + return encodePng(out, w, FRAME); +} + +async function animate(still: Uint8Array, look: string, direction: string): Promise { + const big = encodePng(nn(still, FRAME, FRAME, 2), FRAME * 2, FRAME * 2); + let lastErr = ""; + for (let attempt = 0; attempt < 4; attempt++) { + const res = await fetch("https://api.pixellab.ai/v1/animate-with-text", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey()}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + image_size: { width: FRAME * 2, height: FRAME * 2 }, + description: look, + action: "walk", + reference_image: { type: "base64", base64: Buffer.from(big).toString("base64") }, + view: "low top-down", + direction, + n_frames: 4, + }), + }); + if (res.ok) { + const body = (await res.json()) as { images?: { base64?: string }[] }; + return (body.images ?? []).map((img) => { + const d = decodePng(new Uint8Array(Buffer.from(img.base64!, "base64"))); + return shrink2(d.rgba, d.width, d.height); + }); + } + lastErr = `${res.status} ${await res.text()}`; + if (res.status === 422 || res.status === 401) break; + await new Promise((r) => setTimeout(r, 2000 * (attempt + 1))); + } + throw new Error(`animate-with-text(${direction}): ${lastErr}`); +} + +const force = process.argv.includes("--force"); +const HERO_LOOK = "young man, shoulder-length dark brown hair, short beard, white collared shirt, blue jeans"; + +// hero: 4-frame cycles, standing still as frame 0 of each row +{ + const out = ART + "spr_hero.png"; + if (!force && (await Bun.file(out).exists())) { + console.log("skip spr_hero (cached)"); + } else { + const rows: Uint8Array[] = []; + for (const [suffix, dir] of [ + ["s", "south"], + ["n", "north"], + ["e", "east"], + ] as const) { + const still = await loadFrame(`${ART}walk_hero_${suffix}.png`); + process.stdout.write(` animate hero ${dir}... `); + const frames = await animate(still, HERO_LOOK, dir); + if (frames.length < 3) throw new Error(`only ${frames.length} frames for ${dir}`); + // row = still + walk frames 2,3,4 (frame 1 is usually closest to the still) + rows.push(still, ...frames.slice(1, 4)); + console.log(`ok (${frames.length} frames)`); + } + await Bun.write(out, sheet(rows)); + console.log("wrote spr_hero.png (12 frames)"); + } +} + +// npcs: 3-still sheets +for (const who of ["kid", "dad", "woz", "res", "team"]) { + const out = `${ART}spr_${who}.png`; + if (!force && (await Bun.file(out).exists())) { + console.log(`skip spr_${who} (cached)`); + continue; + } + const frames = await Promise.all( + (["s", "n", "e"] as const).map((sfx) => loadFrame(`${ART}walk_${who}_${sfx}.png`)), + ); + await Bun.write(out, sheet(frames)); + console.log(`wrote spr_${who}.png (3 frames)`); +} diff --git a/saga/play.sh b/saga/play.sh new file mode 100644 index 0000000..7f8fbb3 --- /dev/null +++ b/saga/play.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# saga/play.sh — build REALITY DISTORTION and open it in mGBA. +# bash saga/play.sh [game.ts] +set -euo pipefail +cd "$(dirname "$0")" +GAME="${1:-game/reality-distortion.ts}" +OUT="dist/$(basename "${GAME%.*}").gba" +bun compiler/cli.ts build "$GAME" --out "$OUT" --title REALDISTORT +MGBA_APP="$(brew --prefix mgba 2>/dev/null)/mGBA.app" +if [ -d "$MGBA_APP" ]; then + open -n "$MGBA_APP" --args "$PWD/$OUT" +else + mgba "$OUT" +fi diff --git a/saga/runtime/breakout.c b/saga/runtime/breakout.c new file mode 100644 index 0000000..65d5b05 --- /dev/null +++ b/saga/runtime/breakout.c @@ -0,0 +1,181 @@ +/* saga/runtime/breakout.c — the playable Breakout set piece (Atari, 1976). + * Bricks/ball/paddle are OBJs from the built-in UI sheet over whatever BG the + * scene shows. Deterministic (no rng): launch angle fixed, physics integer. + * OP_BREAKOUT blocks in WAITING_MINIGAME and pushes the number of bricks + * cleared; the game ends on full clear, on running out of lives, or on the + * frame budget expiring — history only needs the night to pass. */ +#include "saga.h" + +#define COURT_X0 24 +#define COURT_X1 216 /* 12 bricks x 16px */ +#define COURT_Y0 16 +#define COURT_Y1 156 +#define BRICK_Y0 32 +#define PADDLE_Y 144 +#define PADDLE_W 32 + +static struct { + u8 active, lives, rows, launched; + u8 left, cleared; + u16 timer, budget; + s16 px; /* paddle left */ + s16 bx_q4, by_q4, dx_q4, dy_q4; /* ball top-left, 12.4 fixed */ + u8 brick[C_BRICK_ROWS_MAX * C_BRICK_COLS]; +} bk; + +u8 breakout_left(void) { + return bk.active ? bk.left : 0; +} + +static void ball_reset(void) { + bk.launched = 0; + bk.bx_q4 = (s16)((bk.px + PADDLE_W / 2 - 4) << 4); + bk.by_q4 = (s16)((PADDLE_Y - 8) << 4); + bk.dx_q4 = 24; /* 1.5 px/frame */ + bk.dy_q4 = -32; /* 2 px/frame up */ +} + +void breakout_start(u8 rows, u8 lives, u16 budget) { + int i; + if (rows > C_BRICK_ROWS_MAX) rows = C_BRICK_ROWS_MAX; + bk.active = 1; + bk.rows = rows; + bk.lives = lives; + bk.cleared = 0; + bk.left = (u8)(rows * C_BRICK_COLS); + bk.timer = 0; + bk.budget = budget ? budget : 3600; + bk.px = 120 - PADDLE_W / 2; + for (i = 0; i < rows * C_BRICK_COLS; i++) bk.brick[i] = 1; + ball_reset(); + g.waiting = WAITING_MINIGAME; +} + +static void finish(void) { + int i; + bk.active = 0; + for (i = 0; i < C_BRICK_ROWS_MAX * C_BRICK_COLS; i++) + oam_shadow[C_OAM_BRICK + i].attr0 = ATTR0_HIDE; + oam_shadow[C_OAM_BALL].attr0 = ATTR0_HIDE; + oam_shadow[C_OAM_PADDLE].attr0 = ATTR0_HIDE; + vm_push((s16)bk.cleared); + g.waiting = WAITING_RUN; +} + +/* returns 1 if a brick at ball point (px coords) was hit */ +static u8 hit_brick(s16 x, s16 y) { + s16 c, r; + if (y < BRICK_Y0 || y >= BRICK_Y0 + bk.rows * 8) return 0; + if (x < COURT_X0 || x >= COURT_X1) return 0; + c = (s16)((x - COURT_X0) >> 4); + r = (s16)((y - BRICK_Y0) >> 3); + if (r < 0 || r >= bk.rows || c < 0 || c >= C_BRICK_COLS) return 0; + if (!bk.brick[r * C_BRICK_COLS + c]) return 0; + bk.brick[r * C_BRICK_COLS + c] = 0; + bk.left--; + bk.cleared++; + sfx_play(C_SFX_BLIP); + return 1; +} + +u8 breakout_service(void) { + s16 bx, by; + if (!bk.active) return 0; + bk.timer++; + + /* paddle */ + if (key_held(KEY_LEFT)) bk.px -= 3; + if (key_held(KEY_RIGHT)) bk.px += 3; + if (bk.px < COURT_X0) bk.px = COURT_X0; + if (bk.px > COURT_X1 - PADDLE_W) bk.px = (s16)(COURT_X1 - PADDLE_W); + + if (!bk.launched) { + bk.bx_q4 = (s16)((bk.px + PADDLE_W / 2 - 4) << 4); + if (key_pressed(KEY_A)) { + bk.launched = 1; + sfx_play(C_SFX_CONFIRM); + } + } else { + bk.bx_q4 += bk.dx_q4; + bk.by_q4 += bk.dy_q4; + bx = (s16)(bk.bx_q4 >> 4); + by = (s16)(bk.by_q4 >> 4); + + if (bx <= COURT_X0) { + bk.bx_q4 = COURT_X0 << 4; + bk.dx_q4 = (s16)-bk.dx_q4; + } else if (bx >= COURT_X1 - 8) { + bk.bx_q4 = (s16)((COURT_X1 - 8) << 4); + bk.dx_q4 = (s16)-bk.dx_q4; + } + if (by <= COURT_Y0) { + bk.by_q4 = COURT_Y0 << 4; + bk.dy_q4 = (s16)-bk.dy_q4; + } + + bx = (s16)(bk.bx_q4 >> 4); + by = (s16)(bk.by_q4 >> 4); + + /* bricks: test ball center against the grid, flip vertical on hit */ + if (hit_brick((s16)(bx + 4), (s16)(bk.dy_q4 < 0 ? by : by + 8))) { + bk.dy_q4 = (s16)-bk.dy_q4; + g.fx[TW_SHAKE] = 1; + } + + /* paddle catch */ + if (bk.dy_q4 > 0 && by + 8 >= PADDLE_Y && by + 8 < PADDLE_Y + 6 && bx + 8 > bk.px && + bx < bk.px + PADDLE_W) { + s16 off = (s16)((bx + 4) - (bk.px + PADDLE_W / 2)); /* -16..16 */ + bk.dy_q4 = (s16)-bk.dy_q4; + bk.dx_q4 = (s16)(off * 2); + if (bk.dx_q4 > 40) bk.dx_q4 = 40; + if (bk.dx_q4 < -40) bk.dx_q4 = -40; + if (bk.dx_q4 > -8 && bk.dx_q4 < 8) bk.dx_q4 = bk.dx_q4 < 0 ? -8 : 8; + sfx_play(C_SFX_BLIP); + } + + /* lost ball */ + if (by > COURT_Y1) { + if (--bk.lives == 0) { + finish(); + return 0; + } + ball_reset(); + } + } + + if (bk.left == 0 || bk.timer >= bk.budget) { + finish(); + return 0; + } + return 1; +} + +void breakout_draw(void) { + int r, c; + if (!bk.active) return; + for (r = 0; r < bk.rows; r++) { + for (c = 0; c < C_BRICK_COLS; c++) { + ObjAttr *o = &oam_shadow[C_OAM_BRICK + r * C_BRICK_COLS + c]; + if (!bk.brick[r * C_BRICK_COLS + c]) { + o->attr0 = ATTR0_HIDE; + continue; + } + o->attr0 = (u16)(ATTR0_Y(BRICK_Y0 + r * 8) | ATTR0_WIDE); + o->attr1 = (u16)(ATTR1_X(COURT_X0 + c * 16) | (0 << 14)); /* 16x8 */ + o->attr2 = (u16)(ATTR2_TILE(C_OBJ_UI_BASE + 26) | ATTR2_PRIO(1) | ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + } + } + { + ObjAttr *o = &oam_shadow[C_OAM_BALL]; + o->attr0 = (u16)(ATTR0_Y(bk.by_q4 >> 4) | ATTR0_SQUARE); + o->attr1 = (u16)(ATTR1_X(bk.bx_q4 >> 4) | (0 << 14)); /* 8x8 */ + o->attr2 = (u16)(ATTR2_TILE(C_OBJ_UI_BASE + 28) | ATTR2_PRIO(1) | ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + } + { + ObjAttr *o = &oam_shadow[C_OAM_PADDLE]; + o->attr0 = (u16)(ATTR0_Y(PADDLE_Y) | ATTR0_WIDE); + o->attr1 = (u16)(ATTR1_X(bk.px) | (1 << 14)); /* 32x8 */ + o->attr2 = (u16)(ATTR2_TILE(C_OBJ_UI_BASE + 29) | ATTR2_PRIO(1) | ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + } +} diff --git a/saga/runtime/caption.c b/saga/runtime/caption.c new file mode 100644 index 0000000..577d77f --- /dev/null +++ b/saga/runtime/caption.c @@ -0,0 +1,317 @@ +/* saga/runtime/caption.c — typewriter captions, dialog and choice UI on BG0. + * + * Text tokens (compiler-paginated, <=2 lines x 26 cells): + * 0x00 end · 0x0a newline · 0x20..0x7e ASCII halfcell · 0x80|hi,lo fullwidth + * Glyphs are 8x16 halfcells: two stacked 4bpp tiles (64 bytes) DMA'd into a + * ring of C_GLYPH_SLOTS slots inside shared charblock 2, one halfcell per + * frame (typewriter) or synchronously (choice menus, speaker chips). + * Halfcells are baked ink-on-box-color, so text always sits on the navy bar. */ +#include "saga.h" + +#define UI_MAP ((u16 *)SCREENBLOCK(C_SBB_UI)) + +/* per-style geometry */ +typedef struct { + u8 box_r0, box_rows; /* cleared/boxed region */ + u8 text_r0; /* first text row (lines at +0 and +2) */ + u8 margin; /* left col for text (0xff = center per line) */ + u8 boxed; /* draw T_BOX under the region */ +} CapGeo; + +static const CapGeo GEO[4] = { + /* CHIP */ {1, 2, 1, 1, 1}, + /* SUB */ {14, 6, 15, 2, 1}, + /* CARD */ {8, 4, 8, 0xff, 0}, + /* DIALOG*/ {12, 8, 15, 2, 1}, +}; + +typedef struct { + const u8 *tok; + u8 style; + u8 line; /* 0/1 */ + u8 col; + u8 pending; /* second halfcell of a fullwidth glyph (hc+1), 0 = none */ + u8 starts[C_CAP_LINES]; + u8 emitted; +} Typing; + +static Typing ty; +static u8 choice_active_n; +static u8 choice_r0; + +static const u8 *text_tokens(u16 id) { + return film.text_blob + film.text_offs[id]; +} + +/* measure token stream: cell widths per line */ +static u8 measure(const u8 *t, u8 *w0, u8 *w1) { + u8 w[C_CAP_LINES] = {0, 0}; + u8 line = 0; + for (;;) { + u8 c = *t++; + if (c == C_TOK_END) break; + if (c == C_TOK_NL) { + if (++line >= C_CAP_LINES) break; + continue; + } + if (c & 0x80) { + t++; /* low byte */ + w[line] = (u8)(w[line] + 2); + } else { + w[line]++; + } + } + *w0 = w[0]; + *w1 = w[1]; + return (u8)(line + 1); +} + +static void wipe_rows(u8 r0, u8 rows) { + int r, c; + for (r = r0; r < r0 + rows; r++) + for (c = 0; c < 32; c++) UI_MAP[r * 32 + c] = 0; +} + +static void box_rows(u8 r0, u8 rows) { + int r, c; + for (r = r0; r < r0 + rows; r++) + for (c = 0; c < 30; c++) UI_MAP[r * 32 + c] = SE(C_T_BOX, C_PALBANK_UI); +} + +static void accent_row(u8 r, u8 c0, u8 c1) { + int c; + for (c = c0; c < c1; c++) UI_MAP[r * 32 + c] = SE(C_T_BOX_ACCENT, C_PALBANK_UI); +} + +/* Slot plan: the chip caption (place/date) persists across a whole scene while + * subs/dialogs churn, so it gets a private slot range; everything else shares + * a ring above it. */ +#define CHIP_SLOTS 24 +static u16 chip_next; + +static u8 cur_region; /* 0 = general ring, 1 = chip range */ + +static void emit_halfcell(u16 hc, u8 col, u8 row) { + u16 slot; + u16 tile; + if (cur_region) { + slot = chip_next; + chip_next = (u16)((slot + 1) % CHIP_SLOTS); + } else { + slot = g.slot_next; + g.slot_next = (u16)(slot + 1); + if (g.slot_next >= C_GLYPH_SLOTS) g.slot_next = CHIP_SLOTS; + } + tile = (u16)(C_GLYPH_SLOT_BASE + slot * 2); + dma3_copy32(CHARBLOCK(C_CBB_SHARED) + tile * 16, film.glyphs + (u32)hc * 64, 64 / 4); + UI_MAP[row * 32 + col] = SE(tile, C_PALBANK_UI); + UI_MAP[(row + 1) * 32 + col] = (u16)SE(tile + 1, C_PALBANK_UI); +} + +/* fullwidth halfcell ids exceed a u8; the pending right half lives in a u16 */ +static u16 ty_pend16; + +static void type_start(u8 style, u16 text_id) { + const CapGeo *ge = &GEO[style]; + const u8 *t = text_tokens(text_id); + u8 w0, w1; + measure(t, &w0, &w1); + ty.tok = t; + ty.style = style; + ty.line = 0; + ty.emitted = 0; + ty_pend16 = 0; + ty.pending = 0; + if (ge->margin == 0xff) { + ty.starts[0] = (u8)((30 - w0) / 2); + ty.starts[1] = (u8)((30 - (w1 ? w1 : w0)) / 2); + } else { + ty.starts[0] = ge->margin; + ty.starts[1] = ge->margin; + } + ty.col = ty.starts[0]; + g.caption_busy = 1; +} + +/* one halfcell per frame, honoring a pending fullwidth right half */ +void caption_update(void) { + const CapGeo *ge; + u8 row; + if (!g.caption_busy) return; + cur_region = (ty.style == C_CAP_CHIP); + ge = &GEO[ty.style]; + row = (u8)(ge->text_r0 + ty.line * 2); + if (ty_pend16) { + emit_halfcell(ty_pend16, ty.col++, row); + ty_pend16 = 0; + return; + } + for (;;) { + u8 c; + if (!ty.tok) { + g.caption_busy = 0; + return; + } + c = *ty.tok++; + if (c == C_TOK_END) { + ty.tok = 0; + g.caption_busy = 0; + return; + } + if (c == C_TOK_NL) { + ty.line++; + if (ty.line >= C_CAP_LINES) { + ty.tok = 0; + g.caption_busy = 0; + return; + } + ty.col = ty.starts[ty.line]; + row = (u8)(ge->text_r0 + ty.line * 2); + continue; + } + if (c & 0x80) { + u16 gid = (u16)(((c & 0x7f) << 8) | *ty.tok++); + u16 hc = (u16)(C_ASCII_HALF + gid * 2); + emit_halfcell(hc, ty.col++, row); + ty_pend16 = (u16)(hc + 1); + } else { + emit_halfcell((u16)(c - 0x20), ty.col++, row); + } + if ((++ty.emitted & 3) == 1) sfx_play(C_SFX_BLIP); + return; + } +} + +u8 caption_typing(void) { + return g.caption_busy; +} + +/* draw a whole token stream synchronously at (col0, row) */ +static void render_now(const u8 *t, u8 col0, u8 row) { + u8 col = col0; + cur_region = 0; + for (;;) { + u8 c = *t++; + if (c == C_TOK_END) break; + if (c == C_TOK_NL) { + row += 2; + col = col0; + continue; + } + if (c & 0x80) { + u16 gid = (u16)(((c & 0x7f) << 8) | *t++); + u16 hc = (u16)(C_ASCII_HALF + gid * 2); + emit_halfcell(hc, col++, row); + emit_halfcell((u16)(hc + 1), col++, row); + } else { + emit_halfcell((u16)(c - 0x20), col++, row); + } + } +} + +void caption_show(u8 style, u16 text_id) { + const CapGeo *ge = &GEO[style]; + /* finish any in-flight typing instantly */ + while (g.caption_busy) caption_update(); + wipe_rows(ge->box_r0, ge->box_rows); + if (ge->boxed) { + if (style == C_CAP_CHIP) { + u8 w0, w1; + measure(text_tokens(text_id), &w0, &w1); + { + int r, c; + for (r = ge->box_r0; r < ge->box_r0 + 2; r++) + for (c = 0; c < 2 + w0; c++) UI_MAP[r * 32 + c] = SE(C_T_BOX, C_PALBANK_UI); + accent_row((u8)(ge->box_r0 + 2), 0, (u8)(2 + w0)); + } + } else { + box_rows(ge->box_r0, ge->box_rows); + } + } + type_start(style, text_id); + g.cur_text = (u16)(text_id + 1); +} + +void caption_clear(u8 style) { + if (style == 0xff) { + wipe_rows(0, 20); + } else { + const CapGeo *ge = &GEO[style]; + wipe_rows(ge->box_r0, ge->box_rows); + if (style == C_CAP_CHIP) wipe_rows((u8)(ge->box_r0 + 2), 1); + } + if (g.caption_busy && (style == 0xff || style == ty.style)) { + ty.tok = 0; + ty_pend16 = 0; + g.caption_busy = 0; + } +} + +void caption_dialog(u16 speaker, u16 body) { + const CapGeo *ge = &GEO[C_CAP_DIALOG]; + while (g.caption_busy) caption_update(); + wipe_rows(ge->box_r0, ge->box_rows); + box_rows(ge->box_r0, ge->box_rows); + render_now(text_tokens(speaker), 2, (u8)(ge->box_r0 + 0)); /* rows 12-13 */ + accent_row((u8)(ge->box_r0 + 2), 1, 29); /* row 14 */ + type_start(C_CAP_DIALOG, body); /* rows 15.. */ + g.cur_text = (u16)(body + 1); +} + +/* --- choice menu --------------------------------------------------------------- */ +static void choice_cursor_draw(u8 on) { + u8 r = (u8)(choice_r0 + g.choice_cursor * 2); + UI_MAP[r * 32 + 2] = on ? SE(C_T_CURSOR, C_PALBANK_UI) : SE(C_T_BOX, C_PALBANK_UI); +} + +void choice_show(u8 n, const u16 *ids) { + int i; + while (g.caption_busy) caption_update(); + g.choice_n = n; + g.choice_cursor = 0; + choice_r0 = (u8)(18 - 2 * n); + choice_active_n = n; + wipe_rows((u8)(choice_r0 - 1), (u8)(2 * n + 3)); + box_rows((u8)(choice_r0 - 1), (u8)(2 * n + 3)); + for (i = 0; i < n; i++) render_now(text_tokens(ids[i]), 4, (u8)(choice_r0 + i * 2)); + choice_cursor_draw(1); +} + +void choice_update(void) { + if (!choice_active_n) return; + if (key_pressed(KEY_UP) && g.choice_cursor > 0) { + choice_cursor_draw(0); + g.choice_cursor--; + choice_cursor_draw(1); + sfx_play(C_SFX_BLIP); + } + if (key_pressed(KEY_DOWN) && g.choice_cursor + 1 < g.choice_n) { + choice_cursor_draw(0); + g.choice_cursor++; + choice_cursor_draw(1); + sfx_play(C_SFX_BLIP); + } +} + +u8 choice_done(s8 *out) { + if (!choice_active_n) return 0; + if (key_pressed(KEY_A)) { + *out = (s8)g.choice_cursor; + wipe_rows((u8)(choice_r0 - 1), (u8)(2 * choice_active_n + 3)); + choice_active_n = 0; + sfx_play(C_SFX_CONFIRM); + return 1; + } + return 0; +} + +void caption_boot(void) { + wipe_rows(0, 20); + ty.tok = 0; + ty_pend16 = 0; + g.caption_busy = 0; + g.slot_next = CHIP_SLOTS; + chip_next = 0; + cur_region = 0; + choice_active_n = 0; +} diff --git a/saga/runtime/crt0.s b/saga/runtime/crt0.s new file mode 100644 index 0000000..6cb1651 --- /dev/null +++ b/saga/runtime/crt0.s @@ -0,0 +1,53 @@ +@ saga/runtime/crt0.s — GBA cartridge header + startup for @pocketjs/saga. +@ Logo (0x04) and complement (0xBD) are patched by saga/compiler/rom.ts. + .section .init, "ax" + .global _start + .cpu arm7tdmi + .align 2 + .arm +_start: + b .Lstart + .space 156, 0 @ Nintendo logo (patched) + .ascii "SAGA " @ 0xA0: title (12) + .ascii "PJCE" @ 0xAC: game code + .ascii "PJ" @ 0xB0: maker code + .byte 0x96 + .byte 0x00 + .byte 0x00 + .space 7, 0 + .byte 0x00 + .byte 0x00 @ complement (patched) + .space 2, 0 +.Lstart: + @ IRQ-mode stack + msr cpsr_c, #0xD2 + ldr sp, =0x03007FA0 + @ system-mode stack, IRQs ENABLED at the CPU (the saga runtime is + @ interrupt-driven: VBlank frame sync + HBlank raster FX) + msr cpsr_c, #0x5F + ldr sp, =0x03007F00 + + @ copy .data + .iwram code (ROM LMA -> IWRAM VMA) + ldr r0, =__data_lma + ldr r1, =__data_start + ldr r2, =__data_end +.Lcopy: + cmp r1, r2 + ldrlo r3, [r0], #4 + strlo r3, [r1], #4 + blo .Lcopy + + @ zero .bss + ldr r1, =__bss_start + ldr r2, =__bss_end + mov r3, #0 +.Lbss: + cmp r1, r2 + strlo r3, [r1], #4 + blo .Lbss + + ldr r0, =main + mov lr, pc + bx r0 +.Lhang: + b .Lhang diff --git a/saga/runtime/cue_vm.c b/saga/runtime/cue_vm.c new file mode 100644 index 0000000..9ae969b --- /dev/null +++ b/saga/runtime/cue_vm.c @@ -0,0 +1,433 @@ +/* saga/runtime/cue_vm.c — the suspendable cue interpreter. Non-blocking ops + * (tweens, sprite ops, raster, sfx) execute in a burst each frame; blocking + * ops (wait/fade/caption/dialog/choice/control/mash) park the VM in a waiting + * state that vm_tick services first. */ +#include "saga.h" + +static u8 rd8(void) { + return g.sc->cue[g.ip++]; +} +static u16 rd16(void) { + u16 v = (u16)(g.sc->cue[g.ip] | (g.sc->cue[g.ip + 1] << 8)); + g.ip += 2; + return v; +} +static s16 rds16(void) { + return (s16)rd16(); +} + +static void push(s16 v) { + if (g.sp < 8) g.stack[g.sp++] = v; +} +static s16 pop(void) { + return g.sp ? g.stack[--g.sp] : 0; +} + +void vm_push(s16 v) { + push(v); +} + +/* free-roam interrupt: run an NPC/trigger cue, then resume the in-flight + * OP_WORLD (ip is saved because the sub-cue moves it) */ +void vm_run_sub(u8 cue) { + if (cue == 0xff || cue >= g.sc->n_cues) return; + g.ret_ip = g.ip; + g.in_sub = 1; + g.ip = g.sc->cue_offs[cue]; + g.waiting = WAITING_RUN; +} + +void vm_start(void) { + g.ip = 0; + g.sp = 0; + g.waiting = WAITING_RUN; +} + +/* --- waiting-state service ---------------------------------------------------- */ +static void service_control(void) { + Spr *s = &g.spr[g.ctl_slot]; + static s16 frac; + s16 step = 0; + if (key_held(KEY_LEFT)) step = (s16)-g.ctl_speed_q4; + else if (key_held(KEY_RIGHT)) step = (s16)g.ctl_speed_q4; + if (step) { + frac += step; + s->x += frac / 16; + frac %= 16; + s->flags = step < 0 ? (u8)(s->flags | C_SPR_HFLIP) : (u8)(s->flags & ~C_SPR_HFLIP); + s->mode = 1; /* walk anim */ + } else { + s->mode = 0; + s->frame = 0; + } + /* soft camera follow */ + { + s16 target = (s16)(s->x - 120); + if (target < g.sc->cam_min) target = g.sc->cam_min; + if (target > g.sc->cam_max) target = g.sc->cam_max; + g.fx[TW_CAM_X] += (s16)((target - g.fx[TW_CAM_X]) >> 3); + } + /* clamp actor to the pannable world */ + if (s->x < g.sc->cam_min + 8) s->x = (s16)(g.sc->cam_min + 8); + if (s->x > g.sc->cam_max + 232) s->x = (s16)(g.sc->cam_max + 232); + if (s->x > g.ctl_exit - 3 && s->x < g.ctl_exit + 3) { + s->mode = 0; + s->frame = 0; + g.waiting = WAITING_RUN; + } +} + +static void service(void) { + switch (g.waiting) { + case WAITING_BUSY: + if (g.wk_active) { + walk_service(); + if (!g.wk_active && !g.wait_frames && !g.caption_busy) g.waiting = WAITING_RUN; + break; + } + if (g.wait_frames) { + g.wait_frames--; + if (g.wait_frames == 0 && !g.caption_busy) g.waiting = WAITING_RUN; + } else if (!g.caption_busy) { + g.waiting = WAITING_RUN; + } + break; + case WAITING_WORLD: + world_service(); + break; + case WAITING_MINIGAME: + breakout_service(); + break; + case WAITING_A: + g.prompt_on = 1; + if (key_pressed(KEY_A)) { + g.prompt_on = 0; + sfx_play(C_SFX_CONFIRM); + g.waiting = WAITING_RUN; + } + break; + case WAITING_DIALOG: + if (g.caption_busy) { + if (key_pressed(KEY_A)) { /* fast-forward typing */ + while (g.caption_busy) caption_update(); + } + break; + } + g.prompt_on = 1; + if (key_pressed(KEY_A)) { + g.prompt_on = 0; + caption_clear(C_CAP_DIALOG); + sfx_play(C_SFX_CONFIRM); + g.waiting = WAITING_RUN; + } + break; + case WAITING_CHOICE: { + s8 out; + choice_update(); + if (choice_done(&out)) { + g.last_choice = out; + push(out); + g.waiting = WAITING_RUN; + } + break; + } + case WAITING_CONTROL: + service_control(); + break; + case WAITING_MASH: + if (key_pressed(KEY_A)) { + g.vars[g.mash_var]++; + sfx_play(C_SFX_STAR); + } + if (g.vars[g.mash_var] >= (s16)g.mash_target) g.waiting = WAITING_RUN; + break; + default: + break; + } +} + +/* wait for all tweens */ +static u8 tweens_running(void) { + return g.tween_mask != 0; +} + +void vm_tick(void) { + int budget = 64; + + if (g.waiting == WAITING_FILM_DONE) return; + if (g.waiting != WAITING_RUN) { + service(); + if (g.waiting != WAITING_RUN) return; + } + + while (budget-- > 0) { + u8 op = rd8(); + switch (op) { + case OP_END: + if (g.in_sub) { + /* NPC/trigger cue done: resume the roam the player never left */ + g.in_sub = 0; + g.ip = g.ret_ip; + g.waiting = WAITING_WORLD; + return; + } + if (g.scene + 1 < film.n_scenes) { + g.pending_scene = (u8)(g.scene + 1); + } else { + g.film_done = 1; + g.waiting = WAITING_FILM_DONE; + } + return; + case OP_WAIT: + g.wait_frames = rd16(); + g.waiting = WAITING_BUSY; + return; + case OP_WAITA: + g.waiting = WAITING_A; + return; + case OP_WAIT_TWEENS: + if (tweens_running()) { + g.ip--; /* re-run this op next frame */ + return; + } + break; + case OP_FADE: { + u8 mode = rd8(); + u16 T = rd16(); + g.fade_mode = mode; + tween_start(TW_BLDY, (mode == C_FADE_IN_BLACK || mode == C_FADE_IN_WHITE) ? 0 : 16, T, + C_EASE_LINEAR); + g.wait_frames = T; + g.waiting = WAITING_BUSY; + return; + } + case OP_CAPTION: { + u8 style = rd8(); + u16 id = rd16(); + caption_show(style, id); + g.waiting = WAITING_BUSY; + g.wait_frames = 0; + return; + } + case OP_CAPTION_CLR: + caption_clear(rd8()); + break; + case OP_DIALOG: { + u16 sp = rd16(); + u16 body = rd16(); + caption_dialog(sp, body); + g.waiting = WAITING_DIALOG; + return; + } + case OP_CHOICE: { + u8 n = rd8(); + int i; + for (i = 0; i < n; i++) g.choice_ids[i] = rd16(); + choice_show(n, g.choice_ids); + g.waiting = WAITING_CHOICE; + return; + } + case OP_TWEEN: { + u8 target = rd8(); + u8 ease = rd8(); + s16 to = rds16(); + u16 T = rd16(); + tween_start(target, to, T, ease); + break; + } + case OP_SPRITE_SHOW: { + u8 slot = rd8(); + u8 proto = rd8(); + s16 x = rds16(); + s16 y = rds16(); + u8 flags = rd8(); + Spr *s = &g.spr[slot]; + s->active = 1; + s->proto = proto; + s->x = x; + s->y = y; + s->flags = flags; + s->mode = 0; + s->frame = 0; + s->timer = 0; + s->fps = g.sc->protos[proto].fps; + s->affine = 0; + break; + } + case OP_SPRITE_HIDE: + g.spr[rd8()].active = 0; + break; + case OP_SPRITE_ANIM: { + u8 slot = rd8(); + u8 mode = rd8(); + u8 arg = rd8(); + Spr *s = &g.spr[slot]; + s->mode = mode; + if (mode == 0) s->frame = arg; + else if (arg) s->fps = arg; + break; + } + case OP_SPRITE_MOVE: { + u8 slot = rd8(); + u8 ease = rd8(); + s16 x = rds16(); + s16 y = rds16(); + u16 T = rd16(); + tween_start((u8)(C_TW_SPRITE_BASE + slot * 2), x, T, ease); + tween_start((u8)(C_TW_SPRITE_BASE + slot * 2 + 1), y, T, ease); + break; + } + case OP_CONTROL: + g.ctl_slot = rd8(); + g.ctl_exit = rds16(); + g.ctl_speed_q4 = rd8(); + g.waiting = WAITING_CONTROL; + return; + case OP_MASH: + g.mash_var = rd8(); + g.mash_target = rd16(); + g.waiting = WAITING_MASH; + return; + case OP_GOTO_SCENE: + g.pending_scene = rd8(); + return; + case OP_RASTER: { + u8 mode = rd8(); + u8 amp = rd8(); + g.raster_mode = mode; + if (mode == C_RASTER_WAVE_MAIN || mode == C_RASTER_WAVE_FAR) g.fx[TW_WAVE_AMP] = amp; + break; + } + case OP_SFX: + sfx_play(rd8()); + break; + case OP_COUNTER: { + u8 var = rd8(); + u8 show = rd8(); + s16 x = rds16(); + s16 y = rds16(); + g.counter_var = var; + g.counter_show = show; + g.counter_x = x; + g.counter_y = y; + g.counter_prev = g.vars[var]; + break; + } + case OP_AFFINE: { + u8 slot = rd8(); + g.spr[slot].affine = rd8(); + break; + } + case OP_LETTERBOX: { + u8 px = rd8(); + u16 T = rd16(); + tween_start(TW_LETTERBOX, px, T, C_EASE_INOUT); + break; + } + case OP_WORLD: + world_enter(); + return; + case OP_BREAKOUT: { + u8 rows = rd8(); + u8 lives = rd8(); + u16 budget = rd16(); + breakout_start(rows, lives, budget); + return; + } + case OP_METER: { + u8 id = (u8)(rd8() & 1); + u8 var = rd8(); + s16 x = rds16(); + s16 y = rds16(); + u8 max = rd8(); + u8 show = rd8(); + g.meter_var[id] = var; + g.meter_x[id] = x; + g.meter_y[id] = y; + g.meter_max[id] = max ? max : 1; + g.meter_on[id] = show; + break; + } + case OP_WARP: { + u8 cx = rd8(); + u8 cy = rd8(); + u8 dir = rd8(); + world_warp(cx, cy, dir); + break; + } + case OP_FACE: { + u8 slot = rd8(); + u8 dir = rd8(); + world_face(slot, dir); + break; + } + case OP_WALK: { + u8 slot = rd8(); + u8 cx = rd8(); + u8 cy = rd8(); + walk_start(slot, cx, cy); + g.waiting = WAITING_BUSY; + return; + } + case OP_PUSH: + push(rds16()); + break; + case OP_SET_VAR: + g.vars[rd8()] = pop(); + break; + case OP_GET_VAR: + push(g.vars[rd8()]); + break; + case OP_ADD_VAR: { + u8 v = rd8(); + g.vars[v] = (s16)(g.vars[v] + rds16()); + break; + } + case OP_SET_FLAG: + FLAG_SET(rd8()); + break; + case OP_CLR_FLAG: + FLAG_CLR(rd8()); + break; + case OP_GET_FLAG: + push((s16)FLAG_GET(rd8())); + break; + case OP_CMP: { + u8 k = rd8(); + s16 b = pop(); + s16 a = pop(); + s16 r = 0; + switch (k) { + case C_CMP_EQ: r = a == b; break; + case C_CMP_NE: r = a != b; break; + case C_CMP_LT: r = a < b; break; + case C_CMP_GT: r = a > b; break; + case C_CMP_LE: r = a <= b; break; + case C_CMP_GE: r = a >= b; break; + } + push(r); + break; + } + case OP_JZ: { + u16 to = rd16(); + if (pop() == 0) g.ip = to; + break; + } + case OP_JMP: + g.ip = rd16(); + break; + case OP_RND: { + u8 n = rd8(); + push((s16)((g.rng >> 4) % n)); + break; + } + case OP_POP: + pop(); + break; + default: + /* corrupt cue: halt scene */ + g.waiting = WAITING_FILM_DONE; + return; + } + } +} diff --git a/saga/runtime/debug.c b/saga/runtime/debug.c new file mode 100644 index 0000000..53e53b3 --- /dev/null +++ b/saga/runtime/debug.c @@ -0,0 +1,30 @@ +/* saga/runtime/debug.c — fixed EWRAM debug block; the E2E contract. */ +#include "saga.h" + +#define D8(off) (*(vu8 *)(C_DEBUG_ADDR + (off))) +#define D16(off) (*(vu16 *)(C_DEBUG_ADDR + (off))) +#define D32(off) (*(vu32 *)(C_DEBUG_ADDR + (off))) + +void debug_flush(void) { + int i; + D32(DBGO_MAGIC) = DBG_MAGIC_VAL; + D8(DBGO_BOOTED) = 1; + D8(DBGO_SCENE) = g.scene; + D8(DBGO_WAITING) = g.waiting; + D8(DBGO_LAST_CHOICE) = (u8)g.last_choice; + D16(DBGO_FRAME) = g.frame; + D16(DBGO_CUE_IP) = g.ip; + D16(DBGO_CAM_X) = (u16)g.fx[TW_CAM_X]; + D16(DBGO_CUR_TEXT) = g.cur_text; + D16(DBGO_TWEEN_MASK) = g.tween_mask; + D8(DBGO_CAPTION_BUSY) = g.caption_busy; + D8(DBGO_FILM_DONE) = g.film_done; + for (i = 0; i < C_N_VARS; i++) D16(DBGO_VARS + i * 2) = (u16)g.vars[i]; + D16(DBGO_SPR0_X) = (u16)g.spr[0].x; + D16(DBGO_SPR0_Y) = (u16)g.spr[0].y; + D8(DBGO_PLAYER_CX) = g.pl_cx; + D8(DBGO_PLAYER_CY) = g.pl_cy; + D8(DBGO_PLAYER_DIR) = g.pl_dir; + D8(DBGO_BRICKS) = breakout_left(); + D8(DBGO_KIND) = g.sc ? g.sc->kind : 0; +} diff --git a/saga/runtime/fx.c b/saga/runtime/fx.c new file mode 100644 index 0000000..595cb3c --- /dev/null +++ b/saga/runtime/fx.c @@ -0,0 +1,124 @@ +/* saga/runtime/fx.c — maps tween-target values onto GBA effect hardware: + * camera/parallax scroll, BLDCNT fades (black/white) and BG1 ghost alpha, + * mosaic, WIN0 letterbox (raster-assisted for the bars), screen shake, and + * OBJ affine matrix 0 (scale/angle). Runs once per frame after tween_step. */ +#include "saga.h" + +extern s8 sin8[256]; + +void fx_reset(void) { + int i; + for (i = 0; i < 16; i++) g.fx[i] = 0; + g.fx[TW_EVA] = 16; + g.fx[TW_EVB] = 0; + g.fx[TW_OBJ_SCALE] = 256; + g.far_off_q8 = 0; + g.sky_off_q8 = 0; + for (i = 0; i < C_MAX_TWEENS; i++) g.tw[i].active = 0; + g.tween_mask = 0; +} + +static s16 clamp16(s32 v, s32 lo, s32 hi) { + if (v < lo) return (s16)lo; + if (v > hi) return (s16)hi; + return (s16)v; +} + +void fx_apply(void) { + const SagaScene *sc = g.sc; + s16 cam = g.fx[TW_CAM_X]; + s16 camy = g.fx[TW_CAM_Y]; + s16 shake = g.fx[TW_SHAKE]; + s16 sx = 0, sy = 0; + u16 dispcnt; + + if (shake > 0) { + sx = (s16)((g.rng >> 4) % (u16)(2 * shake + 1)) - shake; + sy = (s16)((g.rng >> 9) % (u16)(shake + 1)) - (shake >> 1); + } + + /* autoscroll accumulators */ + g.far_off_q8 += sc->far_vx_q8 + g.fx[TW_FAR_VX]; + g.sky_off_q8 += sc->sky_vx_q8 + g.fx[TW_SKY_VX]; + + isr_hofs[0] = 0; + isr_vofs[0] = 0; + isr_hofs[1] = (u16)(cam + sx); + isr_vofs[1] = (u16)(camy + sy); + isr_hofs[2] = (u16)((((s32)cam * sc->far_fac_q8) >> 8) + (s16)(g.far_off_q8 >> 8) + sx); + isr_vofs[2] = (u16)(((s32)camy * sc->far_fac_q8) >> 8); + isr_hofs[3] = (u16)((((s32)cam * sc->sky_fac_q8) >> 8) + (s16)(g.sky_off_q8 >> 8)); + isr_vofs[3] = 0; + + /* --- blending state machine ------------------------------------------------ */ + { + s16 y = g.fx[TW_BLDY]; + if (y < 0) y = 0; + if (y > 16) y = 16; + if (y > 0) { + REG_BLDCNT = (u16)(((g.fade_mode >= C_FADE_IN_WHITE) ? BLD_MODE_WHITE : BLD_MODE_BLACK) | BLD_ALL); + REG_BLDY = (u16)y; + } else if (g.fx[TW_EVA] != 16 || g.fx[TW_EVB] != 0) { + /* ghost/crossfade: BG1(+OBJ) over far/sky/backdrop */ + REG_BLDCNT = (u16)(BLD_MODE_ALPHA | BLD_BG1 | BLD_OBJ | BLD_2ND(BLD_BG2 | BLD_BG3 | BLD_BD)); + REG_BLDALPHA = (u16)((g.fx[TW_EVA] & 31) | ((g.fx[TW_EVB] & 31) << 8)); + REG_BLDY = 0; + } else { + /* idle: keep 2nd targets armed so SPR_GHOST OBJs still blend */ + REG_BLDCNT = (u16)(BLD_MODE_OFF | BLD_2ND(BLD_BG1 | BLD_BG2 | BLD_BG3 | BLD_BD)); + REG_BLDALPHA = (u16)(10 | (8 << 8)); + REG_BLDY = 0; + } + } + + /* --- mosaic ------------------------------------------------------------------ */ + { + u16 m = (u16)(g.fx[TW_MOSAIC] & 15); + REG_MOSAIC = (u16)(m | (m << 4) | (m << 8) | (m << 12)); + } + + /* --- letterbox (WIN0 band + ISR blackout outside) ----------------------------- */ + { + s16 lb = clamp16(g.fx[TW_LETTERBOX], 0, 48); + isr_lb = (u16)lb; + dispcnt = DCNT_MODE0 | DCNT_OBJ | DCNT_OBJ_1D | DCNT_BG0 | DCNT_BG1; + if (sc->map_far) dispcnt |= DCNT_BG2; + if (sc->map_sky) dispcnt |= DCNT_BG3; + if (lb > 0) { + REG_WIN0H = 240; + REG_WIN0V = (u16)((lb << 8) | (160 - lb)); + REG_WININ = WIN_ALL; + REG_WINOUT = WIN_BLD; /* backdrop only (ISR paints it black) */ + dispcnt |= DCNT_WIN0; + } + REG_DISPCNT = dispcnt; + } + + /* --- raster feed ---------------------------------------------------------------- */ + isr_backdrop = sc->backdrop; + isr_grad = (g.raster_mode == C_RASTER_GRADIENT && sc->gradient) ? sc->gradient : 0; + if (g.raster_mode == C_RASTER_WAVE_MAIN || g.raster_mode == C_RASTER_WAVE_FAR) { + isr_wave_bg = (g.raster_mode == C_RASTER_WAVE_MAIN) ? 1 : 2; + isr_wave_amp = (u16)(g.fx[TW_WAVE_AMP] < 0 ? 0 : g.fx[TW_WAVE_AMP]); + } else { + isr_wave_amp = 0; + } + + /* --- OBJ affine matrix 0 ----------------------------------------------------------- */ + { + s16 scale = g.fx[TW_OBJ_SCALE]; + u8 ang = (u8)g.fx[TW_OBJ_ANGLE]; + s32 c = sin8[(u8)(ang + 64)]; /* q7 cos */ + s32 s = sin8[ang]; + s32 inv; /* q8 of 1/scale */ + ObjAffine *m = &OAM_AFF[0]; + ObjAffine *ms = (ObjAffine *)oam_shadow; + if (scale < 16) scale = 16; + inv = 65536 / scale; + ms->pa = (s16)((c * inv) >> 7); + ms->pb = (s16)((-s * inv) >> 7); + ms->pc = (s16)((s * inv) >> 7); + ms->pd = (s16)((c * inv) >> 7); + (void)m; + } +} diff --git a/saga/runtime/gba.h b/saga/runtime/gba.h new file mode 100644 index 0000000..ad650ae --- /dev/null +++ b/saga/runtime/gba.h @@ -0,0 +1,179 @@ +/* saga/runtime/gba.h — GBA hardware definitions for the saga runtime. + * Fuller register set than aot's: blending, mosaic, windows, affine OBJ, + * HBlank/VBlank IRQs, PSG sound. Freestanding, per GBATEK/Tonc. */ +#ifndef SAGA_GBA_H +#define SAGA_GBA_H +#include + +typedef uint8_t u8; +typedef uint16_t u16; +typedef uint32_t u32; +typedef int8_t s8; +typedef int16_t s16; +typedef int32_t s32; +typedef volatile uint8_t vu8; +typedef volatile uint16_t vu16; +typedef volatile uint32_t vu32; + +#define MEM_EWRAM 0x02000000 +#define MEM_IWRAM 0x03000000 +#define MEM_IO 0x04000000 +#define MEM_PAL 0x05000000 +#define MEM_VRAM 0x06000000 +#define MEM_OAM 0x07000000 + +/* --- display ---------------------------------------------------------------- */ +#define REG_DISPCNT (*(vu16 *)(MEM_IO + 0x0000)) +#define REG_DISPSTAT (*(vu16 *)(MEM_IO + 0x0004)) +#define REG_VCOUNT (*(vu16 *)(MEM_IO + 0x0006)) +#define REG_BGCNT(n) (*(vu16 *)(MEM_IO + 0x0008 + (n) * 2)) +#define REG_BGHOFS(n) (*(vu16 *)(MEM_IO + 0x0010 + (n) * 4)) +#define REG_BGVOFS(n) (*(vu16 *)(MEM_IO + 0x0012 + (n) * 4)) + +#define DCNT_MODE0 0x0000 +#define DCNT_FORCE_BLANK 0x0080 +#define DCNT_BG0 0x0100 +#define DCNT_BG1 0x0200 +#define DCNT_BG2 0x0400 +#define DCNT_BG3 0x0800 +#define DCNT_OBJ 0x1000 +#define DCNT_WIN0 0x2000 +#define DCNT_OBJ_1D 0x0040 + +#define DSTAT_VBL_IRQ 0x0008 +#define DSTAT_HBL_IRQ 0x0010 + +#define BG_4BPP 0x0000 +#define BG_MOSAIC 0x0040 +#define BG_PRIO(n) ((n) & 3) +#define BG_CBB(n) (((n) & 3) << 2) +#define BG_SBB(n) (((n) & 0x1f) << 8) +#define BG_SIZE_32x32 0x0000 +#define BG_SIZE_64x32 0x4000 +#define BG_SIZE_32x64 0x8000 +#define BG_SIZE_64x64 0xC000 + +/* --- effects ------------------------------------------------------------------ */ +#define REG_MOSAIC (*(vu16 *)(MEM_IO + 0x004c)) +#define REG_BLDCNT (*(vu16 *)(MEM_IO + 0x0050)) +#define REG_BLDALPHA (*(vu16 *)(MEM_IO + 0x0052)) +#define REG_BLDY (*(vu16 *)(MEM_IO + 0x0054)) + +#define BLD_BG0 0x0001 +#define BLD_BG1 0x0002 +#define BLD_BG2 0x0004 +#define BLD_BG3 0x0008 +#define BLD_OBJ 0x0010 +#define BLD_BD 0x0020 +#define BLD_ALL 0x003f +#define BLD_MODE_OFF 0x0000 +#define BLD_MODE_ALPHA 0x0040 +#define BLD_MODE_WHITE 0x0080 +#define BLD_MODE_BLACK 0x00c0 +#define BLD_2ND(t) ((t) << 8) + +/* --- windows -------------------------------------------------------------------- */ +#define REG_WIN0H (*(vu16 *)(MEM_IO + 0x0040)) +#define REG_WIN0V (*(vu16 *)(MEM_IO + 0x0044)) +#define REG_WININ (*(vu16 *)(MEM_IO + 0x0048)) +#define REG_WINOUT (*(vu16 *)(MEM_IO + 0x004a)) +#define WIN_BG0 0x01 +#define WIN_BG1 0x02 +#define WIN_BG2 0x04 +#define WIN_BG3 0x08 +#define WIN_OBJ 0x10 +#define WIN_BLD 0x20 +#define WIN_ALL 0x3f + +/* --- interrupts ----------------------------------------------------------------- */ +#define REG_IE (*(vu16 *)(MEM_IO + 0x0200)) +#define REG_IF (*(vu16 *)(MEM_IO + 0x0202)) +#define REG_IME (*(vu16 *)(MEM_IO + 0x0208)) +#define IRQ_VBLANK 0x0001 +#define IRQ_HBLANK 0x0002 +#define ISR_VECTOR (*(vu32 *)(0x03007ffc)) +#define BIOS_IF (*(vu16 *)(0x03007ff8)) + +/* --- input ------------------------------------------------------------------------ */ +#define REG_KEYINPUT (*(vu16 *)(MEM_IO + 0x0130)) +#define KEY_A 0x0001 +#define KEY_B 0x0002 +#define KEY_SELECT 0x0004 +#define KEY_START 0x0008 +#define KEY_RIGHT 0x0010 +#define KEY_LEFT 0x0020 +#define KEY_UP 0x0040 +#define KEY_DOWN 0x0080 +#define KEY_MASK 0x03ff + +/* --- PSG sound -------------------------------------------------------------------- */ +#define REG_SOUNDCNT_L (*(vu16 *)(MEM_IO + 0x0080)) +#define REG_SOUNDCNT_H (*(vu16 *)(MEM_IO + 0x0082)) +#define REG_SOUNDCNT_X (*(vu16 *)(MEM_IO + 0x0084)) +#define REG_SOUND1CNT_L (*(vu16 *)(MEM_IO + 0x0060)) /* sweep */ +#define REG_SOUND1CNT_H (*(vu16 *)(MEM_IO + 0x0062)) /* duty/len/env */ +#define REG_SOUND1CNT_X (*(vu16 *)(MEM_IO + 0x0064)) /* freq/ctrl */ +#define REG_SOUND4CNT_L (*(vu16 *)(MEM_IO + 0x0078)) /* noise len/env */ +#define REG_SOUND4CNT_H (*(vu16 *)(MEM_IO + 0x007c)) /* noise freq/ctrl */ + +/* --- VRAM / palettes ----------------------------------------------------------------- */ +#define CHARBLOCK(n) ((u16 *)(MEM_VRAM + (n) * 0x4000)) +#define SCREENBLOCK(n) ((u16 *)(MEM_VRAM + (n) * 0x800)) +#define OBJ_VRAM ((u16 *)(MEM_VRAM + 0x10000)) +#define BG_PAL ((vu16 *)MEM_PAL) +#define OBJ_PAL ((vu16 *)(MEM_PAL + 0x200)) +#define SE(tile, palbank) (((tile) & 0x3ff) | (((palbank) & 0xf) << 12)) +#define SE_HFLIP 0x0400 +#define SE_VFLIP 0x0800 + +/* --- OAM ------------------------------------------------------------------------------- */ +typedef struct { + u16 attr0, attr1, attr2, fill; +} ObjAttr; +typedef struct { + u16 f0[3]; + s16 pa; + u16 f1[3]; + s16 pb; + u16 f2[3]; + s16 pc; + u16 f3[3]; + s16 pd; +} ObjAffine; +#define OAM_MEM ((ObjAttr *)MEM_OAM) +#define OAM_AFF ((ObjAffine *)MEM_OAM) + +#define ATTR0_Y(y) ((y) & 0xff) +#define ATTR0_AFFINE 0x0100 +#define ATTR0_HIDE 0x0200 +#define ATTR0_AFF_DBL 0x0300 +#define ATTR0_BLEND 0x0400 +#define ATTR0_MOSAIC 0x1000 +#define ATTR0_SQUARE 0x0000 +#define ATTR0_WIDE 0x4000 +#define ATTR0_TALL 0x8000 +#define ATTR1_X(x) ((x) & 0x1ff) +#define ATTR1_AFF(n) (((n) & 31) << 9) +#define ATTR1_HFLIP 0x1000 +#define ATTR1_VFLIP 0x2000 +#define ATTR1_SIZE(n) (((n) & 3) << 14) +#define ATTR2_TILE(t) ((t) & 0x3ff) +#define ATTR2_PRIO(p) (((p) & 3) << 10) +#define ATTR2_PALBANK(n) (((n) & 0xf) << 12) + +/* --- DMA3 -------------------------------------------------------------------------------- */ +#define REG_DMA3SAD (*(vu32 *)(MEM_IO + 0x00d4)) +#define REG_DMA3DAD (*(vu32 *)(MEM_IO + 0x00d8)) +#define REG_DMA3CNT (*(vu32 *)(MEM_IO + 0x00dc)) +#define DMA_ENABLE 0x80000000 +#define DMA_32 0x04000000 + +static inline void dma3_copy32(volatile void *dst, const void *src, u32 words) { + REG_DMA3SAD = (u32)src; + REG_DMA3DAD = (u32)dst; + REG_DMA3CNT = words | DMA_ENABLE | DMA_32; +} + +#define IWRAM_CODE __attribute__((section(".iwram.text"), long_call, target("arm"))) + +#endif diff --git a/saga/runtime/gba.ld b/saga/runtime/gba.ld new file mode 100644 index 0000000..d94bb42 --- /dev/null +++ b/saga/runtime/gba.ld @@ -0,0 +1,44 @@ +/* saga/runtime/gba.ld — GBA link map for @pocketjs/saga. + .iwram.text (the HBlank ISR) is copied to IWRAM together with .data so the + per-scanline raster handler never pays ROM waitstates. EWRAM stays free for + the debug block at 0x02000000. */ +OUTPUT_FORMAT("elf32-littlearm") +OUTPUT_ARCH(arm) +ENTRY(_start) + +MEMORY { + rom (rx) : ORIGIN = 0x08000000, LENGTH = 32M + iwram (rwx) : ORIGIN = 0x03000000, LENGTH = 32K + ewram (rwx) : ORIGIN = 0x02000000, LENGTH = 256K +} + +SECTIONS { + .init : { + KEEP(*(.init)) + *(.text .text.*) + *(.rodata .rodata.*) + . = ALIGN(4); + } > rom + + __data_lma = .; + .data : AT (__data_lma) { + . = ALIGN(4); + __data_start = .; + *(.iwram .iwram.*) + . = ALIGN(4); + *(.data .data.*) + . = ALIGN(4); + __data_end = .; + } > iwram + + .bss (NOLOAD) : { + . = ALIGN(4); + __bss_start = .; + *(.bss .bss.*) + *(COMMON) + . = ALIGN(4); + __bss_end = .; + } > iwram + + /DISCARD/ : { *(.comment) *(.note*) *(.ARM.attributes) } +} diff --git a/saga/runtime/input.c b/saga/runtime/input.c new file mode 100644 index 0000000..703ac62 --- /dev/null +++ b/saga/runtime/input.c @@ -0,0 +1,16 @@ +/* saga/runtime/input.c */ +#include "saga.h" + +void input_poll(void) { + g.keys_prev = g.keys; + g.keys = (u16)(~REG_KEYINPUT) & KEY_MASK; + g.rng = g.rng * 25173 + 13849; +} + +u16 key_held(u16 m) { + return g.keys & m; +} + +u16 key_pressed(u16 m) { + return g.keys & ~g.keys_prev & m; +} diff --git a/saga/runtime/irq.c b/saga/runtime/irq.c new file mode 100644 index 0000000..a6d847c --- /dev/null +++ b/saga/runtime/irq.c @@ -0,0 +1,79 @@ +/* saga/runtime/irq.c — VBlank/HBlank interrupt service. + * + * The HBlank handler is the raster engine: per scanline it writes the backdrop + * color (sky gradients get ~160 shades from a 15-color-per-bank system; the + * letterbox forces the out-of-window band to black) and, when a wave effect is + * active, a sine offset into one BG's HOFS. It runs from IWRAM (.iwram.text, + * copied by crt0) so it fits comfortably inside the 272-cycle HBlank. + * + * The VBlank handler DMAs the OAM shadow, latches the frame's scroll values, + * preloads line 0's backdrop, and bumps the frame counter. */ +#include "saga.h" + +volatile u32 vbl_count; +u16 isr_hofs[4], isr_vofs[4]; +u16 isr_lb; +const u16 *isr_grad; +u16 isr_backdrop; +u16 isr_wave_amp; +u8 isr_wave_bg; +u16 isr_wave_phase; + +s8 sin8[256]; /* q7 sine, built at boot (quarter-wave parabola — FX grade) */ + +ObjAttr oam_shadow[128]; + +IWRAM_CODE static void master_isr(void) { + u16 f = REG_IF; + if (f & IRQ_HBLANK) { + u32 vc = REG_VCOUNT; + u32 line = (vc >= 227) ? 0 : vc + 1; /* the line about to be drawn */ + if (line < 160) { + u16 c; + if (isr_lb && (line < isr_lb || line >= 160u - isr_lb)) c = 0; + else if (isr_grad) c = isr_grad[line]; + else c = isr_backdrop; + BG_PAL[0] = c; + if (isr_wave_amp) { + u32 idx = (line * 3 + isr_wave_phase) & 255; + s32 s = sin8[idx]; + REG_BGHOFS(isr_wave_bg) = (u16)(isr_hofs[isr_wave_bg] + ((s * (s32)isr_wave_amp) >> 7)); + } + } + } + if (f & IRQ_VBLANK) { + int i; + dma3_copy32(OAM_MEM, oam_shadow, sizeof(oam_shadow) / 4); + for (i = 0; i < 4; i++) { + REG_BGHOFS(i) = isr_hofs[i]; + REG_BGVOFS(i) = isr_vofs[i]; + } + BG_PAL[0] = isr_lb ? 0 : (isr_grad ? isr_grad[0] : isr_backdrop); + isr_wave_phase += 2; + vbl_count++; + } + REG_IF = f; + BIOS_IF |= f; +} + +void irq_init(void) { + int i; + for (i = 0; i < 128; i++) { + /* parabola per quadrant: peaks ~127 at i=64 */ + s32 v = (i * (128 - i)) >> 5; + if (v > 127) v = 127; + sin8[i] = (s8)v; + sin8[128 + i] = (s8)-v; + } + REG_IME = 0; + ISR_VECTOR = (u32)master_isr; + REG_DISPSTAT = DSTAT_VBL_IRQ | DSTAT_HBL_IRQ; + REG_IE = IRQ_VBLANK | IRQ_HBLANK; + REG_IF = 0xffff; + REG_IME = 1; +} + +void frame_wait(void) { + u32 f = vbl_count; + while (vbl_count == f) {} +} diff --git a/saga/runtime/main.c b/saga/runtime/main.c new file mode 100644 index 0000000..ed218bb --- /dev/null +++ b/saga/runtime/main.c @@ -0,0 +1,27 @@ +/* saga/runtime/main.c — fixed frame loop. */ +#include "saga.h" + +int main(void) { + video_boot(); + sfx_boot(); + irq_init(); + scene_load(0); + debug_flush(); + + for (;;) { + input_poll(); + if (g.pending_scene != 0xff) scene_load(g.pending_scene); + vm_tick(); + tween_step(); + sprites_update(); + caption_update(); + fx_apply(); + sprites_draw(); + meters_draw(); + breakout_draw(); + debug_flush(); + g.frame++; + frame_wait(); + } + return 0; +} diff --git a/saga/runtime/obj.c b/saga/runtime/obj.c new file mode 100644 index 0000000..29ad7aa --- /dev/null +++ b/saga/runtime/obj.c @@ -0,0 +1,146 @@ +/* saga/runtime/obj.c — scene sprites, the var-bound digit counter HUD, and the + * blinking A prompt. Everything draws into oam_shadow; the VBlank ISR DMAs it. */ +#include "saga.h" + +#define UI_TILE_PROMPT (C_OBJ_UI_BASE) +#define UI_TILE_DIGIT(d) (C_OBJ_UI_BASE + 4 + (d) * 2) +#define UI_TILE_METER_FULL (C_OBJ_UI_BASE + 24) +#define UI_TILE_METER_EMPTY (C_OBJ_UI_BASE + 25) + +/* attr0 shape / attr1 size for w x h */ +static u16 shape_bits(u8 w, u8 h) { + if (w == h) return ATTR0_SQUARE; + return (w > h) ? ATTR0_WIDE : ATTR0_TALL; +} +static u16 size_bits(u8 w, u8 h) { + u8 m = (w > h) ? w : h; + u8 n = (w > h) ? h : w; + if (w == h) return (u16)(w == 8 ? 0 : w == 16 ? 1 : w == 32 ? 2 : 3); + if (m == 16) return 0; /* 16x8 */ + if (m == 32 && n == 8) return 1; /* 32x8 */ + if (m == 32) return 2; /* 32x16 */ + return 3; /* 64x32 */ +} + +void sprites_update(void) { + int i; + for (i = 0; i < C_MAX_SPRITES; i++) { + Spr *s = &g.spr[i]; + const SagaProto *p; + if (!s->active || s->mode != 1) continue; + p = &g.sc->protos[s->proto]; + if (p->frames <= 1) continue; + if (++s->timer >= s->fps) { + s->timer = 0; + s->frame++; + if (s->frame >= p->frames) s->frame = 0; + } + } + if (g.counter_bounce) g.counter_bounce--; +} + +s16 spr_screen_x(const Spr *s) { + return (s->flags & C_SPR_SCREEN) ? s->x : (s16)(s->x - g.fx[TW_CAM_X]); +} + +void sprites_draw(void) { + int i; + u16 mos = (g.fx[TW_MOSAIC] > 0) ? ATTR0_MOSAIC : 0; + + for (i = 0; i < C_MAX_SPRITES; i++) { + Spr *s = &g.spr[i]; + ObjAttr *o = &oam_shadow[i]; + const SagaProto *p; + s16 sx, sy; + if (!s->active) { + o->attr0 = ATTR0_HIDE; + continue; + } + p = &g.sc->protos[s->proto]; + sx = spr_screen_x(s); + sy = (s->flags & C_SPR_SCREEN) ? s->y : (s16)(s->y - g.fx[TW_CAM_Y]); + if (sx <= -(s16)p->w * 2 || sx >= 240 + p->w || sy <= -(s16)p->h * 2 || sy >= 160 + p->h) { + o->attr0 = ATTR0_HIDE; + continue; + } + if (s->affine) { + /* matrix 0, double-size render area, centered on (x,y) */ + o->attr0 = (u16)(ATTR0_Y(sy - p->h) | ATTR0_AFF_DBL | shape_bits(p->w, p->h) | mos | + ((s->flags & C_SPR_GHOST) ? ATTR0_BLEND : 0)); + o->attr1 = (u16)(ATTR1_X(sx - p->w) | ATTR1_AFF(0) | (size_bits(p->w, p->h) << 14)); + } else { + o->attr0 = (u16)(ATTR0_Y(sy) | shape_bits(p->w, p->h) | mos | + ((s->flags & C_SPR_GHOST) ? ATTR0_BLEND : 0)); + o->attr1 = (u16)(ATTR1_X(sx) | ((s->flags & C_SPR_HFLIP) ? ATTR1_HFLIP : 0) | + (size_bits(p->w, p->h) << 14)); + } + o->attr2 = (u16)(ATTR2_TILE(p->tile_base + s->frame * ((p->w / 8) * (p->h / 8))) | + ATTR2_PRIO((s->flags & C_SPR_BEHIND) ? 3 : 1) | ATTR2_PALBANK(p->palbank)); + } + + /* counter HUD: right-aligned digits bound to a var */ + { + s16 v = g.counter_show ? g.vars[g.counter_var] : -1; + int d; + if (g.counter_show && v != g.counter_prev) { + g.counter_bounce = 8; + g.counter_prev = v; + } + for (d = 0; d < C_COUNTER_DIGITS; d++) { + ObjAttr *o = &oam_shadow[C_OAM_COUNTER + d]; + if (!g.counter_show || v < 0) { + o->attr0 = ATTR0_HIDE; + continue; + } + { + s16 dx = (s16)(g.counter_x - d * 9); + s16 dy = (s16)(g.counter_y - ((g.counter_bounce > 4) ? (g.counter_bounce - 4) : 0)); + u16 digit = (u16)(v % 10); + o->attr0 = (u16)(ATTR0_Y(dy) | ATTR0_TALL); + o->attr1 = (u16)(ATTR1_X(dx) | (0 << 14)); /* 8x16 */ + o->attr2 = (u16)(ATTR2_TILE(UI_TILE_DIGIT(digit)) | ATTR2_PRIO(0) | ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + v /= 10; + if (v == 0 && d + 1 < C_COUNTER_DIGITS) { + int k; + for (k = d + 1; k < C_COUNTER_DIGITS; k++) oam_shadow[C_OAM_COUNTER + k].attr0 = ATTR0_HIDE; + break; + } + } + } + } + + /* blinking A prompt */ + { + ObjAttr *o = &oam_shadow[C_OAM_PROMPT]; + u8 blink = (g.frame & 31) < 22; + if (g.prompt_on && blink) { + o->attr0 = (u16)(ATTR0_Y(142) | ATTR0_SQUARE); + o->attr1 = (u16)(ATTR1_X(220) | (1 << 14)); /* 16x16 */ + o->attr2 = (u16)(ATTR2_TILE(UI_TILE_PROMPT) | ATTR2_PRIO(0) | ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + } else { + o->attr0 = ATTR0_HIDE; + } + } +} + +/* encounter meters: var-bound segment bars (conviction/resolve) */ +void meters_draw(void) { + int m, i; + for (m = 0; m < 2; m++) { + s16 v; + s16 filled; + for (i = 0; i < C_METER_SEGS; i++) oam_shadow[C_OAM_METER + m * C_METER_SEGS + i].attr0 = ATTR0_HIDE; + if (!g.meter_on[m]) continue; + v = g.vars[g.meter_var[m]]; + if (v < 0) v = 0; + if (v > g.meter_max[m]) v = g.meter_max[m]; + filled = (s16)((v * C_METER_SEGS + g.meter_max[m] - 1) / g.meter_max[m]); /* ceil */ + for (i = 0; i < C_METER_SEGS; i++) { + ObjAttr *o = &oam_shadow[C_OAM_METER + m * C_METER_SEGS + i]; + o->attr0 = (u16)(ATTR0_Y(g.meter_y[m]) | ATTR0_SQUARE); + o->attr1 = (u16)(ATTR1_X(g.meter_x[m] + i * 8) | (0 << 14)); /* 8x8 */ + o->attr2 = (u16)(ATTR2_TILE(i < filled ? UI_TILE_METER_FULL : UI_TILE_METER_EMPTY) | + ATTR2_PRIO(0) | ATTR2_PALBANK(C_PALBANK_OBJ_UI)); + } + } +} diff --git a/saga/runtime/saga.h b/saga/runtime/saga.h new file mode 100644 index 0000000..717e58d --- /dev/null +++ b/saga/runtime/saga.h @@ -0,0 +1,269 @@ +/* saga/runtime/saga.h — internal contract of the @pocketjs/saga GBA runtime. + * + * The compiler residualizes a film into gen_data.c: one SagaFilm with per-scene + * palettes, tile sets, tilemaps, OBJ sheets, cue bytecode, gradient tables and + * a global text bank + glyph store. The runtime is fixed; it never parses + * containers — everything is typed arrays in ROM. + * + * Frame order (main.c): input -> vm_tick -> tween_step -> sprites/captions + * update -> fx_apply (registers+shadow) -> debug -> vblank (ISR: OAM DMA, + * scroll regs) -> caption_pump (VRAM glyph streaming). + */ +#ifndef SAGA_H +#define SAGA_H +#include "gba.h" +#include "saga_gen.h" + +/* --- residual data (gen_data.c) ---------------------------------------------- */ +typedef struct { + u16 tile_base; /* in 4bpp-tile units within the scene OBJ sheet */ + u8 w, h; /* pixels: 8,16,32,64 */ + u8 frames; + u8 palbank; + u8 fps; /* default anim speed, frames per cell */ + u8 walk_fpd; /* walker sheet: frames per direction row (0 = plain sprite). + rows: DOWN, UP, SIDE (right = SIDE hflipped) */ +} SagaProto; + +typedef struct { + u8 cx, cy, dir; + u8 slot; /* sprite slot the NPC lives in */ + u8 proto; /* sprite proto index */ + u8 cue; /* cue index run on A-interact (0xff none) */ + u8 solid; +} SagaNpc; + +typedef struct { + u8 cx, cy, w, h; /* cell rect */ + u8 kind; /* C_TRIG_* */ + s16 value; /* EXIT: pushed as OP_WORLD result */ + u8 cue; /* EXAMINE/AUTO: cue index (0xff none) */ +} SagaTrig; + +typedef struct { + u8 cols, rows; /* cells (16px) */ + const u8 *solid; /* cols*rows, 1 = blocked */ + u8 start_cx, start_cy, start_dir; + u8 player_slot; /* sprite slot the player walker lives in */ + u8 player_proto; /* walker proto index */ + const SagaNpc *npcs; + u8 n_npcs; + const SagaTrig *trigs; + u8 n_trigs; +} SagaWorld; + +typedef struct { + const u16 *pal_bg; /* 256 */ + const u16 *pal_obj; /* 256 */ + const u8 *tiles_main; + u16 n_main; /* 4bpp tiles -> charblock 0.. */ + const u8 *tiles_shared; + u16 n_shared; /* -> charblock 2 @ C_FARSKY_BASE */ + const u16 *map_main; /* 32x32; 64x32 when map_sz 1; 64x64 (4 SBBs) when 2 */ + const u16 *map_far; /* 32x32 or 0 (map_sz < 2 only) */ + const u16 *map_sky; /* 32x32 or 0 (map_sz < 2 only) */ + u8 map_sz; /* 0 = 32x32, 1 = 64x32 wide pan, 2 = 64x64 world */ + s16 far_fac_q8, sky_fac_q8; /* parallax factors vs camera */ + s16 far_vx_q8, sky_vx_q8; /* autoscroll */ + const u16 *gradient; /* [160] backdrop per scanline, or 0 */ + const u8 *obj_tiles; + u16 obj_bytes; + const SagaProto *protos; + u8 n_protos; + const u8 *cue; /* all cues, one blob */ + u16 cue_len; + const u16 *cue_offs; /* [n_cues] offsets; cue 0 = play */ + u8 n_cues; + u8 kind; /* C_SCENE_* */ + const SagaWorld *world; /* kind == WORLD */ + s16 cam0, cam_min, cam_max; + u8 raster_mode, raster_amp; + u8 letterbox0; + u16 backdrop; /* PAL[0] when no gradient */ +} SagaScene; + +typedef struct { + const SagaScene *scenes; + u8 n_scenes; + const u32 *text_offs; /* [n_texts] into text_blob */ + const u8 *text_blob; + u16 n_texts; + const u8 *glyphs; /* halfcells: 64 bytes each (two stacked 4bpp tiles) */ + u16 n_halfcells; + const u8 *ui_bg_tiles; /* 4 tiles: blank, box, accent, cursor */ + const u8 *ui_obj_tiles; /* A-prompt 16x16 + digits 0-9 8x16 */ + u16 ui_obj_bytes; +} SagaFilm; + +extern const SagaFilm film; + +/* --- runtime state ------------------------------------------------------------ */ +typedef struct { + u8 active, proto, flags, mode; /* mode: 0 static, 1 loop */ + s16 x, y; /* world px (or screen px when SPR_SCREEN) */ + u8 frame, timer, fps; + u8 affine; /* uses matrix 0, double-size */ +} Spr; + +typedef struct { + u8 active, target, ease; + s16 from, to; + u16 t, T; +} Tween; + +typedef struct { + const SagaScene *sc; + u8 scene; + u8 pending_scene; /* 0xff none */ + u16 frame; + u8 film_done; + u8 raster_mode; /* current RASTER_* (scene default, RASTER op overrides) */ + + /* cue vm */ + u16 ip; + s16 stack[8]; + u8 sp; + u8 waiting; /* WAITING_* */ + u16 wait_frames; + u8 fade_mode; /* FADE op in flight */ + u8 ctl_slot; + s16 ctl_exit; + u8 ctl_speed_q4; + u8 mash_var; + u16 mash_target; + s8 last_choice; + + /* choice ui */ + u8 choice_n, choice_cursor; + u16 choice_ids[C_MAX_CHOICES]; + + /* world mode (top-down grid) */ + u8 pl_cx, pl_cy, pl_dir; + u8 pl_step; /* frames left in current 16px step (0 = idle) */ + s8 pl_dx, pl_dy; + u8 anim_phase; /* walk animation phase counter */ + u8 in_sub; /* running an NPC/trigger cue from free roam */ + u16 ret_ip; /* not used to jump — roam resumes at the in-flight WORLD op */ + u16 trig_seen; /* TRIG_AUTO one-shot mask */ + s16 cam_max_x, cam_max_y; + + /* scripted walk (OP_WALK) */ + u8 wk_active, wk_slot; + s16 wk_tx, wk_ty; /* target px (sprite top-left) */ + + /* meters */ + u8 meter_on[2], meter_var[2], meter_max[2]; + s16 meter_x[2], meter_y[2]; + + /* fx state */ + s16 fx[16]; /* tween-target values, TW_* indexed */ + s32 far_off_q8, sky_off_q8; + Spr spr[C_MAX_SPRITES]; + u16 tween_mask; + Tween tw[C_MAX_TWEENS]; + + /* counter hud */ + u8 counter_show, counter_var; + s16 counter_x, counter_y; + s16 counter_prev; + u8 counter_bounce; + + /* text */ + u16 slot_next; + u16 cur_text; + u8 caption_busy; + u8 prompt_on; /* A-prompt blink master switch */ + + s16 vars[C_N_VARS]; + u16 flags; + + u16 keys, keys_prev; + u16 rng; +} Saga; + +extern Saga g; +extern ObjAttr oam_shadow[128]; + +/* values the ISR consumes (computed in fx_apply) */ +extern volatile u32 vbl_count; +extern u16 isr_hofs[4], isr_vofs[4]; /* final per-BG scroll incl. shake */ +extern u16 isr_lb; /* letterbox px */ +extern const u16 *isr_grad; /* 0 = none */ +extern u16 isr_backdrop; +extern u16 isr_wave_amp; /* px, 0 = off */ +extern u8 isr_wave_bg; /* which BG the wave applies to */ +extern u16 isr_wave_phase; + +/* input.c */ +void input_poll(void); +u16 key_held(u16 m); +u16 key_pressed(u16 m); + +/* irq.c */ +void irq_init(void); +void frame_wait(void); + +/* video.c */ +void video_boot(void); +void scene_load(u8 id); + +/* tween.c */ +void tween_start(u8 target, s16 to, u16 T, u8 ease); +void tween_step(void); +s16 *tween_slot_value(u8 target); /* where a target's value lives */ + +/* fx.c */ +void fx_reset(void); +void fx_apply(void); + +/* obj.c */ +void sprites_update(void); +void sprites_draw(void); /* fills oam_shadow */ +s16 spr_screen_x(const Spr *s); + +/* caption.c */ +void caption_boot(void); +void caption_show(u8 style, u16 text_id); +void caption_clear(u8 style); +void caption_dialog(u16 speaker, u16 body); +void caption_update(void); /* typewriter progress, 1 halfcell/frame */ +u8 caption_typing(void); +void choice_show(u8 n, const u16 *ids); +void choice_update(void); +u8 choice_done(s8 *out); + +/* cue_vm.c */ +void vm_start(void); +void vm_tick(void); +void vm_push(s16 v); +void vm_run_sub(u8 cue); /* jump into an NPC/trigger cue from free roam */ + +/* world.c */ +void world_enter(void); /* OP_WORLD executed: place player, camera, roam on */ +void world_service(void); /* one frame of free roam (WAITING_WORLD) */ +void world_warp(u8 cx, u8 cy, u8 dir); +void world_face(u8 slot, u8 dir); +void walk_start(u8 slot, u8 cx, u8 cy); +u8 walk_service(void); /* 1 while still walking */ + +/* breakout.c */ +void breakout_start(u8 rows, u8 lives, u16 budget); +u8 breakout_service(void); /* 1 while running; pushes result when done */ +void breakout_draw(void); /* fills its OAM slots (bricks/ball/paddle) */ +u8 breakout_left(void); /* bricks remaining (debug) */ + +/* obj.c (meters live with the other HUD OBJs) */ +void meters_draw(void); + +/* sfx.c */ +void sfx_boot(void); +void sfx_play(u8 id); + +/* debug.c */ +void debug_flush(void); + +#define FLAG_GET(i) ((g.flags >> (i)) & 1) +#define FLAG_SET(i) (g.flags |= (u16)(1 << (i))) +#define FLAG_CLR(i) (g.flags &= (u16)~(1 << (i))) + +#endif diff --git a/saga/runtime/saga_gen.h b/saga/runtime/saga_gen.h new file mode 100644 index 0000000..9d11250 --- /dev/null +++ b/saga/runtime/saga_gen.h @@ -0,0 +1,192 @@ +/* saga_gen.h — GENERATED from saga/spec/saga.ts. Do not edit. */ +#ifndef SAGA_GEN_H +#define SAGA_GEN_H +#define C_ASCII_HALF 95 +#define C_BRICK_COLS 12 +#define C_BRICK_ROWS_MAX 6 +#define C_CAP_CARD 2 +#define C_CAP_CHIP 0 +#define C_CAP_COLS 26 +#define C_CAP_DIALOG 3 +#define C_CAP_LINES 2 +#define C_CAP_SUB 1 +#define C_CBB_MAIN 0 +#define C_CBB_SHARED 2 +#define C_CELLS_H 20 +#define C_CELLS_W 30 +#define C_CELL_PX 16 +#define C_CMP_EQ 0 +#define C_CMP_GE 5 +#define C_CMP_GT 3 +#define C_CMP_LE 4 +#define C_CMP_LT 2 +#define C_CMP_NE 1 +#define C_COUNTER_DIGITS 6 +#define C_DBG_MAGIC 1095188819 +#define C_DEBUG_ADDR 33554432 +#define C_DIR_DOWN 0 +#define C_DIR_LEFT 2 +#define C_DIR_RIGHT 3 +#define C_DIR_UP 1 +#define C_EASE_IN 1 +#define C_EASE_INOUT 3 +#define C_EASE_LINEAR 0 +#define C_EASE_OUT 2 +#define C_FADE_IN_BLACK 0 +#define C_FADE_IN_WHITE 2 +#define C_FADE_OUT_BLACK 1 +#define C_FADE_OUT_WHITE 3 +#define C_FARSKY_BASE 4 +#define C_FARSKY_MAX 316 +#define C_GLYPH_SLOTS 96 +#define C_GLYPH_SLOT_BASE 320 +#define C_MAIN_TILE_MAX 1024 +#define C_MAX_CHOICES 5 +#define C_MAX_CUES 24 +#define C_MAX_NPCS 8 +#define C_MAX_PROTOS 12 +#define C_MAX_SCENES 20 +#define C_MAX_SPRITES 16 +#define C_MAX_TRIGS 12 +#define C_MAX_TWEENS 16 +#define C_METER_SEGS 8 +#define C_N_FLAGS 16 +#define C_N_VARS 24 +#define C_OAM_BALL 120 +#define C_OAM_BRICK 48 +#define C_OAM_COUNTER 16 +#define C_OAM_METER 26 +#define C_OAM_PADDLE 121 +#define C_OAM_PROMPT 24 +#define C_OBJ_TILE_MAX 1024 +#define C_OBJ_UI_BASE 960 +#define C_PALBANK_FAR 2 +#define C_PALBANK_MAIN 3 +#define C_PALBANK_OBJ_UI 15 +#define C_PALBANK_SKY 1 +#define C_PALBANK_UI 15 +#define C_RASTER_GRADIENT 1 +#define C_RASTER_OFF 0 +#define C_RASTER_WAVE_FAR 3 +#define C_RASTER_WAVE_MAIN 2 +#define C_SBB_FAR 26 +#define C_SBB_MAIN 24 +#define C_SBB_SKY 27 +#define C_SBB_UI 28 +#define C_SCENE_CINE 0 +#define C_SCENE_WORLD 1 +#define C_SCREEN_H 160 +#define C_SCREEN_W 240 +#define C_SFX_BLIP 0 +#define C_SFX_CONFIRM 1 +#define C_SFX_STAR 3 +#define C_SFX_WHOOSH 2 +#define C_SPR_BEHIND 4 +#define C_SPR_GHOST 8 +#define C_SPR_HFLIP 1 +#define C_SPR_SCREEN 2 +#define C_STEP_FRAMES 8 +#define C_TOK_END 0 +#define C_TOK_NL 10 +#define C_TRIG_AUTO 2 +#define C_TRIG_EXAMINE 1 +#define C_TRIG_EXIT 0 +#define C_TW_SPRITE_BASE 64 +#define C_T_BLANK 0 +#define C_T_BOX 1 +#define C_T_BOX_ACCENT 2 +#define C_T_CURSOR 3 +#define C_UI_ACCENT 3 +#define C_UI_BOX 2 +#define C_UI_INK 1 +#define C_UI_SHADOW 4 +#define C_WALK_ROW_DOWN 0 +#define C_WALK_ROW_SIDE 2 +#define C_WALK_ROW_UP 1 +#define C_WORLD_COLS_MAX 25 +#define C_WORLD_ROWS_MAX 25 +#define OP_END 0 +#define OP_WAIT 1 +#define OP_WAITA 2 +#define OP_WAIT_TWEENS 3 +#define OP_FADE 4 +#define OP_CAPTION 5 +#define OP_CAPTION_CLR 6 +#define OP_DIALOG 7 +#define OP_CHOICE 8 +#define OP_TWEEN 9 +#define OP_SPRITE_SHOW 10 +#define OP_SPRITE_HIDE 11 +#define OP_SPRITE_ANIM 12 +#define OP_SPRITE_MOVE 13 +#define OP_CONTROL 14 +#define OP_MASH 15 +#define OP_GOTO_SCENE 16 +#define OP_RASTER 17 +#define OP_SFX 18 +#define OP_COUNTER 19 +#define OP_AFFINE 20 +#define OP_LETTERBOX 21 +#define OP_WORLD 22 +#define OP_BREAKOUT 23 +#define OP_METER 24 +#define OP_WARP 25 +#define OP_FACE 26 +#define OP_WALK 27 +#define OP_PUSH 32 +#define OP_SET_VAR 33 +#define OP_GET_VAR 34 +#define OP_ADD_VAR 35 +#define OP_SET_FLAG 36 +#define OP_CLR_FLAG 37 +#define OP_GET_FLAG 38 +#define OP_CMP 39 +#define OP_JZ 40 +#define OP_JMP 41 +#define OP_RND 42 +#define OP_POP 43 +#define TW_CAM_X 0 +#define TW_CAM_Y 1 +#define TW_BLDY 2 +#define TW_EVA 3 +#define TW_EVB 4 +#define TW_MOSAIC 5 +#define TW_WAVE_AMP 6 +#define TW_LETTERBOX 7 +#define TW_SHAKE 8 +#define TW_FAR_VX 9 +#define TW_SKY_VX 10 +#define TW_OBJ_SCALE 11 +#define TW_OBJ_ANGLE 12 +#define WAITING_RUN 0 +#define WAITING_A 1 +#define WAITING_DIALOG 2 +#define WAITING_CHOICE 3 +#define WAITING_CONTROL 4 +#define WAITING_MASH 5 +#define WAITING_FILM_DONE 6 +#define WAITING_BUSY 7 +#define WAITING_WORLD 8 +#define WAITING_MINIGAME 9 +#define DBG_MAGIC_VAL 1095188819 +#define DBGO_MAGIC 0 +#define DBGO_BOOTED 4 +#define DBGO_SCENE 5 +#define DBGO_WAITING 6 +#define DBGO_LAST_CHOICE 7 +#define DBGO_FRAME 8 +#define DBGO_CUE_IP 10 +#define DBGO_CAM_X 12 +#define DBGO_CUR_TEXT 14 +#define DBGO_TWEEN_MASK 16 +#define DBGO_CAPTION_BUSY 18 +#define DBGO_FILM_DONE 19 +#define DBGO_VARS 20 +#define DBGO_SPR0_X 68 +#define DBGO_SPR0_Y 70 +#define DBGO_PLAYER_CX 72 +#define DBGO_PLAYER_CY 73 +#define DBGO_PLAYER_DIR 74 +#define DBGO_BRICKS 75 +#define DBGO_KIND 76 +#endif diff --git a/saga/runtime/sfx.c b/saga/runtime/sfx.c new file mode 100644 index 0000000..a26a6bb --- /dev/null +++ b/saga/runtime/sfx.c @@ -0,0 +1,33 @@ +/* saga/runtime/sfx.c — PSG micro-sfx: typewriter blip, confirm, whoosh, star. + * Channel 1 (square w/ sweep) + channel 4 (noise). Deliberately tiny. */ +#include "saga.h" + +void sfx_boot(void) { + REG_SOUNDCNT_X = 0x0080; /* master enable */ + REG_SOUNDCNT_L = 0xff77; /* all PSG channels L+R, max PSG volume */ + REG_SOUNDCNT_H = 0x0002; /* PSG full mix */ +} + +void sfx_play(u8 id) { + switch (id) { + case C_SFX_BLIP: /* short mid square tick */ + REG_SOUND1CNT_L = 0x0000; + REG_SOUND1CNT_H = 0x4140 | (3 << 12); /* duty 25%, env dec fast, quiet */ + REG_SOUND1CNT_X = 0x8000 | 1848; /* ~1.3 kHz */ + break; + case C_SFX_CONFIRM: /* brighter, longer */ + REG_SOUND1CNT_L = 0x0000; + REG_SOUND1CNT_H = 0x0180 | (10 << 12); /* duty 50%, slow decay */ + REG_SOUND1CNT_X = 0x8000 | 1985; /* ~2.1 kHz */ + break; + case C_SFX_WHOOSH: /* noise swell */ + REG_SOUND4CNT_L = (u16)(0x0300 | (9 << 12)); + REG_SOUND4CNT_H = 0x8000 | 0x0034; + break; + case C_SFX_STAR: /* rising sweep chirp */ + REG_SOUND1CNT_L = 0x0017; /* sweep up, shift 7 */ + REG_SOUND1CNT_H = 0x0180 | (9 << 12); + REG_SOUND1CNT_X = 0x8000 | 1750; + break; + } +} diff --git a/saga/runtime/tween.c b/saga/runtime/tween.c new file mode 100644 index 0000000..3fda714 --- /dev/null +++ b/saga/runtime/tween.c @@ -0,0 +1,81 @@ +/* saga/runtime/tween.c — 16-slot property tween engine. + * Targets < 16 index g.fx[] (camera, blend, mosaic, wave, letterbox, shake, + * autoscroll, affine scale/angle). Targets >= C_TW_SPRITE_BASE address sprite + * slot x/y. One active tween per target; restarting replaces. */ +#include "saga.h" + +s16 *tween_slot_value(u8 target) { + if (target >= C_TW_SPRITE_BASE) { + u8 slot = (u8)((target - C_TW_SPRITE_BASE) >> 1); + if (slot >= C_MAX_SPRITES) return 0; + return (target & 1) ? &g.spr[slot].y : &g.spr[slot].x; + } + if (target < 16) return &g.fx[target]; + return 0; +} + +static u16 easef(u16 t, u16 T, u8 mode) { + u32 f = ((u32)t << 8) / T; + switch (mode) { + case C_EASE_IN: + f = (f * f) >> 8; + break; + case C_EASE_OUT: { + u32 inv = 256 - f; + f = 256 - ((inv * inv) >> 8); + break; + } + case C_EASE_INOUT: + f = ((u32)f * f * (768 - 2 * f)) >> 16; + break; + } + return (u16)f; +} + +void tween_start(u8 target, s16 to, u16 T, u8 ease) { + int i, free_i = -1; + s16 *v = tween_slot_value(target); + if (!v) return; + if (T == 0) { + *v = to; + return; + } + for (i = 0; i < C_MAX_TWEENS; i++) { + if (g.tw[i].active && g.tw[i].target == target) { + free_i = i; /* replace in place */ + break; + } + if (!g.tw[i].active && free_i < 0) free_i = i; + } + if (free_i < 0) return; /* out of slots: drop (compiler budget prevents this) */ + g.tw[free_i].active = 1; + g.tw[free_i].target = target; + g.tw[free_i].ease = ease; + g.tw[free_i].from = *v; + g.tw[free_i].to = to; + g.tw[free_i].t = 0; + g.tw[free_i].T = T; +} + +void tween_step(void) { + int i; + u16 mask = 0; + for (i = 0; i < C_MAX_TWEENS; i++) { + Tween *tw = &g.tw[i]; + s16 *v; + if (!tw->active) continue; + v = tween_slot_value(tw->target); + tw->t++; + if (tw->t >= tw->T) { + *v = tw->to; + tw->active = 0; + continue; + } + { + u16 f = easef(tw->t, tw->T, tw->ease); + *v = (s16)(tw->from + (((s32)(tw->to - tw->from) * f) >> 8)); + } + mask |= (u16)(1u << i); + } + g.tween_mask = mask; +} diff --git a/saga/runtime/video.c b/saga/runtime/video.c new file mode 100644 index 0000000..b4ce650 --- /dev/null +++ b/saga/runtime/video.c @@ -0,0 +1,147 @@ +/* saga/runtime/video.c — boot video state + whole-scene VRAM loads. + * Scene loads happen behind force-blank (the author fades/mosaics out first), + * so we can DMA palettes, three tile sets, four tilemaps and the OBJ sheets + * in one go without fighting the PPU. */ +#include "saga.h" + +Saga g; + +static void bgcnt_setup(u8 map_sz) { + u16 sz = map_sz == 2 ? BG_SIZE_64x64 : map_sz == 1 ? BG_SIZE_64x32 : BG_SIZE_32x32; + REG_BGCNT(0) = BG_4BPP | BG_PRIO(0) | BG_CBB(C_CBB_SHARED) | BG_SBB(C_SBB_UI) | BG_SIZE_32x32; + REG_BGCNT(1) = BG_4BPP | BG_PRIO(2) | BG_CBB(C_CBB_MAIN) | BG_SBB(C_SBB_MAIN) | BG_MOSAIC | sz; + REG_BGCNT(2) = BG_4BPP | BG_PRIO(3) | BG_CBB(C_CBB_SHARED) | BG_SBB(C_SBB_FAR) | BG_MOSAIC | BG_SIZE_32x32; + REG_BGCNT(3) = BG_4BPP | BG_PRIO(3) | BG_CBB(C_CBB_SHARED) | BG_SBB(C_SBB_SKY) | BG_SIZE_32x32; +} + +void video_boot(void) { + REG_DISPCNT = DCNT_FORCE_BLANK; + /* fixed UI BG tiles at shared-charblock 0..3 (blank/box/accent/cursor) */ + dma3_copy32(CHARBLOCK(C_CBB_SHARED), film.ui_bg_tiles, (4 * 32) / 4); + /* built-in UI OBJ sheet (A prompt + digits + meter/breakout) at the top */ + dma3_copy32(OBJ_VRAM + C_OBJ_UI_BASE * 16, film.ui_obj_tiles, film.ui_obj_bytes / 4); + bgcnt_setup(0); + g.pending_scene = 0xff; + g.last_choice = -1; + g.rng = 0xbeef; +} + +static void fill_map(u16 *sb, u16 se, u32 count) { + u32 i; + u32 v = se | ((u32)se << 16); + for (i = 0; i < count / 2; i++) ((u32 *)sb)[i] = v; +} + +void scene_load(u8 id) { + const SagaScene *sc = &film.scenes[id]; + int i; + + REG_DISPCNT = DCNT_FORCE_BLANK; + g.scene = id; + g.sc = sc; + g.pending_scene = 0xff; + g.frame = 0; + + /* palettes (backdrop entry 0 is raster-owned; keep a copy) */ + dma3_copy32(BG_PAL, sc->pal_bg, 512 / 4); + dma3_copy32(OBJ_PAL, sc->pal_obj, 512 / 4); + + /* tiles */ + if (sc->n_main) dma3_copy32(CHARBLOCK(C_CBB_MAIN), sc->tiles_main, ((u32)sc->n_main * 32) / 4); + if (sc->n_shared) + dma3_copy32(CHARBLOCK(C_CBB_SHARED) + C_FARSKY_BASE * 16, sc->tiles_shared, ((u32)sc->n_shared * 32) / 4); + /* clear glyph slots */ + { + u32 *gz = (u32 *)(CHARBLOCK(C_CBB_SHARED) + C_GLYPH_SLOT_BASE * 16); + for (i = 0; i < (C_GLYPH_SLOTS * 2 * 32) / 4; i++) gz[i] = 0; + } + + /* maps. map_sz 2 = 64x64: four consecutive screenblocks starting at + * SBB_MAIN (TL,TR,BL,BR), which covers the far/sky blocks — no parallax + * layers in world scenes. */ + if (sc->map_sz == 2) { + dma3_copy32(SCREENBLOCK(C_SBB_MAIN), sc->map_main, 0x2000 / 4); + } else { + dma3_copy32(SCREENBLOCK(C_SBB_MAIN), sc->map_main, (sc->map_sz ? 0x1000u : 0x800u) / 4); + if (sc->map_far) dma3_copy32(SCREENBLOCK(C_SBB_FAR), sc->map_far, 0x800 / 4); + else fill_map(SCREENBLOCK(C_SBB_FAR), 0, 0x400); + if (sc->map_sky) dma3_copy32(SCREENBLOCK(C_SBB_SKY), sc->map_sky, 0x800 / 4); + else fill_map(SCREENBLOCK(C_SBB_SKY), 0, 0x400); + } + fill_map(SCREENBLOCK(C_SBB_UI), 0, 0x400); + + /* OBJ sheets (below the persistent UI sheet) */ + if (sc->obj_bytes) dma3_copy32(OBJ_VRAM, sc->obj_tiles, sc->obj_bytes / 4); + + bgcnt_setup(sc->map_sz); + + /* world + fx reset */ + fx_reset(); + g.fx[TW_CAM_X] = sc->cam0; + g.fx[TW_LETTERBOX] = sc->letterbox0; + g.fx[TW_BLDY] = 16; /* scenes wake up faded out; the cue's FADE reveals */ + g.raster_mode = sc->raster_mode; + if (sc->raster_mode == C_RASTER_WAVE_MAIN || sc->raster_mode == C_RASTER_WAVE_FAR) + g.fx[TW_WAVE_AMP] = sc->raster_amp; + + for (i = 0; i < C_MAX_SPRITES; i++) g.spr[i].active = 0; + for (i = 0; i < 128; i++) oam_shadow[i].attr0 = ATTR0_HIDE; + g.counter_show = 0; + g.counter_bounce = 0; + g.prompt_on = 0; + g.meter_on[0] = g.meter_on[1] = 0; + g.wk_active = 0; + g.in_sub = 0; + g.trig_seen = 0; + g.pl_step = 0; + g.cam_max_x = sc->cam_max; + g.cam_max_y = 0; + + /* world scenes wake up populated: player + NPCs stand on their cells so the + * author's fade-in reveals a living room, before OP_WORLD hands over input */ + if (sc->kind == C_SCENE_WORLD && sc->world) { + const SagaWorld *w = sc->world; + g.cam_max_x = (s16)(w->cols * C_CELL_PX - 240); + g.cam_max_y = (s16)(w->rows * C_CELL_PX - 160); + if (g.cam_max_x < 0) g.cam_max_x = 0; + if (g.cam_max_y < 0) g.cam_max_y = 0; + { + Spr *s = &g.spr[w->player_slot]; + s->active = 1; + s->proto = w->player_proto; + s->mode = 0; + s->frame = 0; + s->timer = 0; + s->flags = 0; + s->affine = 0; + s->fps = sc->protos[s->proto].fps; + } + world_warp(w->start_cx, w->start_cy, w->start_dir); + for (i = 0; i < w->n_npcs; i++) { + const SagaNpc *n = &w->npcs[i]; + Spr *s = &g.spr[n->slot]; + s->active = 1; + s->proto = n->proto; + s->mode = 0; + s->frame = 0; + s->timer = 0; + s->flags = 0; + s->affine = 0; + s->x = (s16)(n->cx * C_CELL_PX + (C_CELL_PX - sc->protos[s->proto].w) / 2); + s->y = (s16)((n->cy + 1) * C_CELL_PX - sc->protos[s->proto].h); + s->fps = sc->protos[s->proto].fps; + world_face(n->slot, n->dir); + } + } + + caption_boot(); + + /* cue vm */ + g.ip = 0; + g.sp = 0; + g.waiting = WAITING_RUN; + g.wait_frames = 0; + g.cur_text = 0; + + fx_apply(); /* also re-enables the display */ +} diff --git a/saga/runtime/world.c b/saga/runtime/world.c new file mode 100644 index 0000000..1b8af78 --- /dev/null +++ b/saga/runtime/world.c @@ -0,0 +1,274 @@ +/* saga/runtime/world.c — top-down grid world: Pokémon-style cell stepping, + * facing/interaction, exit/auto triggers, soft camera follow on both axes, + * and scripted grid walks for cutscenes. Active while the cue VM is parked + * in WAITING_WORLD (OP_WORLD) or during OP_WALK (WAITING_BUSY + wk_active). */ +#include "saga.h" + +static const s8 DX[4] = {0, 0, -1, 1}; /* DOWN, UP, LEFT, RIGHT */ +static const s8 DY[4] = {1, -1, 0, 0}; + +static const SagaWorld *wld(void) { + return g.sc->world; +} + +/* place a sprite's top-left so its feet stand on cell (cx,cy) */ +static void place_on_cell(Spr *s, u8 cx, u8 cy) { + const SagaProto *p = &g.sc->protos[s->proto]; + s->x = (s16)(cx * C_CELL_PX + (C_CELL_PX - p->w) / 2); + s->y = (s16)((cy + 1) * C_CELL_PX - p->h); +} + +/* facing for walker protos: rows DOWN/UP/SIDE, right = SIDE hflipped */ +void world_face(u8 slot, u8 dir) { + Spr *s = &g.spr[slot]; + const SagaProto *p = &g.sc->protos[s->proto]; + u8 row; + if (!p->walk_fpd) return; + row = (dir == C_DIR_DOWN) ? C_WALK_ROW_DOWN : (dir == C_DIR_UP) ? C_WALK_ROW_UP : C_WALK_ROW_SIDE; + s->mode = 0; + s->frame = (u8)(row * p->walk_fpd); + if (dir == C_DIR_RIGHT) s->flags |= C_SPR_HFLIP; + else s->flags &= (u8)~C_SPR_HFLIP; + if (g.sc->world && slot == g.sc->world->player_slot) g.pl_dir = dir; +} + +static void camera_snap(void) { + const SagaWorld *w = wld(); + Spr *s = &g.spr[w->player_slot]; + const SagaProto *p = &g.sc->protos[s->proto]; + s16 tx = (s16)(s->x + p->w / 2 - 120); + s16 ty = (s16)(s->y + p->h / 2 - 80); + if (tx < 0) tx = 0; + if (tx > g.cam_max_x) tx = g.cam_max_x; + if (ty < 0) ty = 0; + if (ty > g.cam_max_y) ty = g.cam_max_y; + g.fx[TW_CAM_X] = tx; + g.fx[TW_CAM_Y] = ty; +} + +static void camera_follow(void) { + const SagaWorld *w = wld(); + Spr *s = &g.spr[w->player_slot]; + const SagaProto *p = &g.sc->protos[s->proto]; + s16 tx = (s16)(s->x + p->w / 2 - 120); + s16 ty = (s16)(s->y + p->h / 2 - 80); + if (tx < 0) tx = 0; + if (tx > g.cam_max_x) tx = g.cam_max_x; + if (ty < 0) ty = 0; + if (ty > g.cam_max_y) ty = g.cam_max_y; + g.fx[TW_CAM_X] += (s16)((tx - g.fx[TW_CAM_X]) >> 3); + g.fx[TW_CAM_Y] += (s16)((ty - g.fx[TW_CAM_Y]) >> 3); +} + +void world_warp(u8 cx, u8 cy, u8 dir) { + const SagaWorld *w = wld(); + Spr *s; + if (!w) return; + s = &g.spr[w->player_slot]; + g.pl_cx = cx; + g.pl_cy = cy; + g.pl_step = 0; + place_on_cell(s, cx, cy); + world_face(w->player_slot, dir); + camera_snap(); +} + +static u8 npc_at(u8 cx, u8 cy, const SagaNpc **out) { + const SagaWorld *w = wld(); + int i; + for (i = 0; i < w->n_npcs; i++) { + if (w->npcs[i].cx == cx && w->npcs[i].cy == cy) { + if (out) *out = &w->npcs[i]; + return 1; + } + } + return 0; +} + +static const SagaTrig *trig_at(u8 cx, u8 cy, u8 kind) { + const SagaWorld *w = wld(); + int i; + for (i = 0; i < w->n_trigs; i++) { + const SagaTrig *t = &w->trigs[i]; + if (t->kind == kind && cx >= t->cx && cx < t->cx + t->w && cy >= t->cy && cy < t->cy + t->h) + return t; + } + return 0; +} + +static u8 blocked(s16 cx, s16 cy) { + const SagaWorld *w = wld(); + const SagaNpc *n; + if (cx < 0 || cy < 0 || cx >= w->cols || cy >= w->rows) return 1; + if (w->solid[cy * w->cols + cx]) return 1; + if (npc_at((u8)cx, (u8)cy, &n) && n->solid) return 1; + return 0; +} + +/* walk-cycle frame while moving: fpd 1 -> waddle (hflip toggle on down/up), + * fpd 2 -> alternate, fpd 3 -> step-stand-step-stand, fpd 4+ -> full cycle */ +static void anim_step(u8 slot, u8 dir) { + Spr *s = &g.spr[slot]; + const SagaProto *p = &g.sc->protos[s->proto]; + u8 row = (dir == C_DIR_DOWN) ? C_WALK_ROW_DOWN : (dir == C_DIR_UP) ? C_WALK_ROW_UP : C_WALK_ROW_SIDE; + u8 ph = (u8)((g.anim_phase >> 3) & 3); + if (!p->walk_fpd) return; + if (p->walk_fpd >= 4) { + s->frame = (u8)(row * p->walk_fpd + ((g.anim_phase >> 2) & 3)); + } else if (p->walk_fpd == 3) { + static const u8 pat[4] = {1, 0, 2, 0}; + s->frame = (u8)(row * p->walk_fpd + pat[ph]); + } else if (p->walk_fpd == 2) { + s->frame = (u8)(row * p->walk_fpd + (ph & 1)); + } else { + s->frame = (u8)(row); + if (dir == C_DIR_DOWN || dir == C_DIR_UP) { + if (ph & 1) s->flags |= C_SPR_HFLIP; + else s->flags &= (u8)~C_SPR_HFLIP; + } + } + if (dir == C_DIR_RIGHT) s->flags |= C_SPR_HFLIP; + else if (dir == C_DIR_LEFT) s->flags &= (u8)~C_SPR_HFLIP; +} + +/* one frame of a 16px step in flight; returns 1 when it just finished */ +static u8 step_advance(void) { + const SagaWorld *w = wld(); + Spr *s = &g.spr[w->player_slot]; + s->x += g.pl_dx; + s->y += g.pl_dy; + g.anim_phase++; + anim_step(w->player_slot, g.pl_dir); + if (--g.pl_step == 0) { + g.pl_cx = (u8)((s16)g.pl_cx + DX[g.pl_dir]); + g.pl_cy = (u8)((s16)g.pl_cy + DY[g.pl_dir]); + place_on_cell(s, g.pl_cx, g.pl_cy); /* snap out drift */ + return 1; + } + return 0; +} + +void world_enter(void) { + if (!wld()) return; + g.waiting = WAITING_WORLD; +} + +void world_service(void) { + const SagaWorld *w = wld(); + Spr *s; + if (!w) { + g.waiting = WAITING_RUN; + return; + } + s = &g.spr[w->player_slot]; + + if (g.pl_step) { + if (step_advance()) { + const SagaTrig *t; + /* landed on a cell: exits end the roam, autos run their cue once */ + if ((t = trig_at(g.pl_cx, g.pl_cy, C_TRIG_EXIT)) != 0) { + world_face(w->player_slot, g.pl_dir); + vm_push(t->value); + g.waiting = WAITING_RUN; + return; + } + if ((t = trig_at(g.pl_cx, g.pl_cy, C_TRIG_AUTO)) != 0) { + u8 bit = (u8)(t - w->trigs); + if (!(g.trig_seen & (1u << bit))) { + g.trig_seen |= (u16)(1u << bit); + world_face(w->player_slot, g.pl_dir); + vm_run_sub(t->cue); + return; + } + } + } + } else { + /* idle on a cell */ + u8 dir = 0xff; + if (key_held(KEY_DOWN)) dir = C_DIR_DOWN; + else if (key_held(KEY_UP)) dir = C_DIR_UP; + else if (key_held(KEY_LEFT)) dir = C_DIR_LEFT; + else if (key_held(KEY_RIGHT)) dir = C_DIR_RIGHT; + + if (key_pressed(KEY_A)) { + s16 fx = (s16)g.pl_cx + DX[g.pl_dir]; + s16 fy = (s16)g.pl_cy + DY[g.pl_dir]; + const SagaNpc *n; + const SagaTrig *t; + if (fx >= 0 && fy >= 0 && fx < w->cols && fy < w->rows && npc_at((u8)fx, (u8)fy, &n) && + n->cue != 0xff) { + /* NPC turns to the player */ + static const u8 OPP[4] = {C_DIR_UP, C_DIR_DOWN, C_DIR_RIGHT, C_DIR_LEFT}; + world_face(n->slot, OPP[g.pl_dir]); + sfx_play(C_SFX_BLIP); + vm_run_sub(n->cue); + return; + } + if (fx >= 0 && fy >= 0 && (t = trig_at((u8)fx, (u8)fy, C_TRIG_EXAMINE)) != 0 && t->cue != 0xff) { + sfx_play(C_SFX_BLIP); + vm_run_sub(t->cue); + return; + } + } + + if (dir != 0xff) { + s16 tx = (s16)g.pl_cx + DX[dir]; + s16 ty = (s16)g.pl_cy + DY[dir]; + if (dir != g.pl_dir) world_face(w->player_slot, dir); + if (!blocked(tx, ty)) { + g.pl_step = C_STEP_FRAMES; + g.pl_dx = (s8)(DX[dir] * (C_CELL_PX / C_STEP_FRAMES)); + g.pl_dy = (s8)(DY[dir] * (C_CELL_PX / C_STEP_FRAMES)); + } else { + anim_step(w->player_slot, dir); /* bump-walk in place */ + g.anim_phase++; + } + } else { + /* stand */ + world_face(w->player_slot, g.pl_dir); + } + } + + camera_follow(); +} + +/* --- scripted walks (OP_WALK, any walker sprite, cutscenes) -------------------- */ +void walk_start(u8 slot, u8 cx, u8 cy) { + const SagaProto *p = &g.sc->protos[g.spr[slot].proto]; + g.wk_active = 1; + g.wk_slot = slot; + g.wk_tx = (s16)(cx * C_CELL_PX + (C_CELL_PX - p->w) / 2); + g.wk_ty = (s16)((cy + 1) * C_CELL_PX - p->h); +} + +u8 walk_service(void) { + Spr *s = &g.spr[g.wk_slot]; + u8 dir; + s16 d; + if (!g.wk_active) return 0; + if (s->x != g.wk_tx) { + d = (s16)(g.wk_tx - s->x); + dir = d > 0 ? C_DIR_RIGHT : C_DIR_LEFT; + s->x += d > 0 ? (d > 2 ? 2 : d) : (d < -2 ? -2 : d); + } else if (s->y != g.wk_ty) { + d = (s16)(g.wk_ty - s->y); + dir = d > 0 ? C_DIR_DOWN : C_DIR_UP; + s->y += d > 0 ? (d > 2 ? 2 : d) : (d < -2 ? -2 : d); + } else { + const SagaWorld *w = g.sc->world; + g.wk_active = 0; + if (w && g.wk_slot == w->player_slot) { + g.pl_cx = (u8)((s->x + g.sc->protos[s->proto].w / 2) / C_CELL_PX); + g.pl_cy = (u8)((s->y + g.sc->protos[s->proto].h - 1) / C_CELL_PX); + world_face(g.wk_slot, g.pl_dir); + camera_snap(); + } else { + world_face(g.wk_slot, C_DIR_DOWN); + } + return 0; + } + g.anim_phase++; + anim_step(g.wk_slot, dir); + if (g.sc->world && g.wk_slot == g.sc->world->player_slot) camera_follow(); + return 1; +} diff --git a/saga/spec/gen-c.ts b/saga/spec/gen-c.ts new file mode 100644 index 0000000..8baffdd --- /dev/null +++ b/saga/spec/gen-c.ts @@ -0,0 +1,26 @@ +// saga/spec/gen-c.ts — mirror spec/saga.ts into runtime/saga_gen.h. +// bun saga/spec/gen-c.ts +import * as S from "./saga.ts"; + +const lines: string[] = [ + "/* saga_gen.h — GENERATED from saga/spec/saga.ts. Do not edit. */", + "#ifndef SAGA_GEN_H", + "#define SAGA_GEN_H", +]; + +const def = (name: string, v: number): void => { + lines.push(`#define ${name} ${v}`); +}; + +for (const [k, v] of Object.entries(S)) { + if (typeof v === "number") def(`C_${k}`, v); +} +for (const [k, v] of Object.entries(S.OP)) def(`OP_${k}`, v); +for (const [k, v] of Object.entries(S.TW)) def(`TW_${k}`, v); +for (const [k, v] of Object.entries(S.WAITING)) def(`WAITING_${k}`, v); +def("DBG_MAGIC_VAL", S.DBG_MAGIC); +for (const [k, v] of Object.entries(S.DBG)) def(`DBGO_${k}`, v); + +lines.push("#endif", ""); +await Bun.write(new URL("../runtime/saga_gen.h", import.meta.url).pathname, lines.join("\n")); +console.log("wrote runtime/saga_gen.h"); diff --git a/saga/spec/saga.ts b/saga/spec/saga.ts new file mode 100644 index 0000000..00ca0ee --- /dev/null +++ b/saga/spec/saga.ts @@ -0,0 +1,298 @@ +// saga/spec/saga.ts — single source of truth for the @pocketjs/saga binary +// contract: cue bytecode, tween targets, VRAM/palette plan, debug block. +// +// @pocketjs/saga is the generalized descendant of @pocketjs/cine: a GBA-only +// interactive-biography DSL. It keeps cine's whole cinematic vocabulary +// (4-layer Mode-0 parallax, BLDCNT fades, HBlank raster FX, letterbox, affine, +// typewriter captions) and adds two playable modes: top-down WORLD scenes +// (Pokémon-style grid walking, NPCs, triggers) and set-piece minigames +// (Breakout) plus encounter UI (meters) — all driven by the same cue VM. +// +// Mirrored into C by spec/gen-c.ts -> runtime/saga_gen.h. + +// --- screen / layer plan ------------------------------------------------------ +export const SCREEN_W = 240; +export const SCREEN_H = 160; +export const CELLS_W = 30; +export const CELLS_H = 20; + +// Mode 0. Fixed semantic layers: +// BG0 = ui (captions/dialog/choice), prio 0, charbase 2, screenblock 28 +// BG1 = main stage, prio 2, charbase 0 (may spill into charblock 1; <=1024 tiles) +// BG2 = far parallax, prio 3, charbase 2, screenblock 26 +// BG3 = sky, prio 3 (drawn under far via BG order), charbase 2, screenblock 27 +export const CBB_MAIN = 0; +export const CBB_SHARED = 2; // ui + far + sky share charblock 2 (512 tiles) +export const SBB_MAIN = 24; // +25 when wide (64x32 map) +export const SBB_FAR = 26; +export const SBB_SKY = 27; +export const SBB_UI = 28; + +// Shared charblock 2 allocation (tile indices relative to charbase 2): +export const T_BLANK = 0; +export const T_BOX = 1; // caption/dialog box fill +export const T_BOX_ACCENT = 2; // vue-green underline tile +export const T_CURSOR = 3; // choice cursor (8x8 arrow) +export const FARSKY_BASE = 4; // far+sky art tiles, up to GLYPH_SLOT area +export const GLYPH_SLOT_BASE = 320; // 96 halfcell slots x 2 stacked tiles = 192 +export const GLYPH_SLOTS = 96; +export const FARSKY_MAX = GLYPH_SLOT_BASE - FARSKY_BASE; // 316 tiles + +export const MAIN_TILE_MAX = 1024; // charblocks 0+1 + +// BG palette banks: +export const PALBANK_SKY = 1; +export const PALBANK_FAR = 2; +export const PALBANK_MAIN = 3; +export const PALBANK_UI = 15; // 0 transparent, 1 ink, 2 box, 3 accent, 4 shadow +export const UI_INK = 1; +export const UI_BOX = 2; +export const UI_ACCENT = 3; +export const UI_SHADOW = 4; + +// OBJ: charblock 4-5 (1024 4bpp tiles), 1D mapping. Last palbank = built-in UI +// sheet (A-prompt 16x16 + digits 0-9 8x16 + meter segs + breakout court pieces) +// appended by the compiler at OBJ_UI_BASE. Scene sheets must stay below it. +export const OBJ_TILE_MAX = 1024; +export const OBJ_UI_BASE = 960; // 64 UI tiles: prompt 0-3, digits 4-23, meter 24-25, brick 26-27, ball 28, paddle 29-32 +export const PALBANK_OBJ_UI = 15; + +// OAM slot plan: 0..15 scene sprites, 16..21 counter digits, 24 A-prompt, +// 26..41 meter segments (2 meters x 8), 48..119 breakout bricks, 120 ball, +// 121 paddle. +export const MAX_SPRITES = 16; +export const OAM_COUNTER = 16; +export const COUNTER_DIGITS = 6; +export const OAM_PROMPT = 24; +export const OAM_METER = 26; +export const METER_SEGS = 8; // 8 segments x 8px = 64px bar +export const OAM_BRICK = 48; +export const BRICK_COLS = 12; +export const BRICK_ROWS_MAX = 6; +export const OAM_BALL = 120; +export const OAM_PADDLE = 121; + +export const MAX_SCENES = 20; +export const MAX_PROTOS = 12; +export const MAX_TWEENS = 16; +export const N_VARS = 24; // named game vars + per-cue locals share this pool +export const N_FLAGS = 16; +export const MAX_CHOICES = 5; + +// --- world (top-down grid) scenes ---------------------------------------------- +// kind: what a scene IS. CINE keeps all four parallax layers; WORLD repurposes +// BG1 as a 64x64 tilemap (screenblocks 24-27, so no far/sky layers) and gives +// the player a grid walker. Encounters are CINE scenes using meters/choices. +export const SCENE_CINE = 0; +export const SCENE_WORLD = 1; + +export const CELL_PX = 16; // one walk cell = 2x2 hardware tiles +export const WORLD_COLS_MAX = 25; // 400px / 16 (pixflux max canvas) +export const WORLD_ROWS_MAX = 25; +export const MAX_NPCS = 8; +export const MAX_TRIGS = 12; +export const MAX_CUES = 24; // per scene: play + npc/trigger cues + +export const DIR_DOWN = 0; +export const DIR_UP = 1; +export const DIR_LEFT = 2; +export const DIR_RIGHT = 3; + +// trigger kinds +export const TRIG_EXIT = 0; // step onto -> OP_WORLD returns, pushes value +export const TRIG_EXAMINE = 1; // face + A -> run cue, then resume roaming +export const TRIG_AUTO = 2; // step onto -> run cue once (sets its seen-flag) + +// walker proto sheet layout: rows DOWN, UP, SIDE (right = SIDE hflipped), +// walk_fpd frames per row; frame 0 of each row = standing. +export const WALK_ROW_DOWN = 0; +export const WALK_ROW_UP = 1; +export const WALK_ROW_SIDE = 2; +export const STEP_FRAMES = 8; // frames per 16px cell step (2px/frame) + +// --- cue bytecode ------------------------------------------------------------- +// One byte op + little-endian args. Blocking ops suspend the VM until done. +export const OP = { + END: 0x00, // scene done -> next scene in film order (or film end) + WAIT: 0x01, // u16 frames + WAITA: 0x02, // blinking A prompt + WAIT_TWEENS: 0x03, + FADE: 0x04, // u8 mode (FADE_*), u16 frames — blocking BLDY fade + CAPTION: 0x05, // u8 style, u16 text — blocking while typing, stays shown + CAPTION_CLR: 0x06, // u8 style (0xff = all) + DIALOG: 0x07, // u16 speaker_text, u16 body_text — type, waitA, clear + CHOICE: 0x08, // u8 n, u16 ids[n] — pushes result + TWEEN: 0x09, // u8 target, u8 ease, s16 to, u16 frames — non-blocking + SPRITE_SHOW: 0x0a, // u8 slot, u8 proto, s16 x, s16 y, u8 flags + SPRITE_HIDE: 0x0b, // u8 slot + SPRITE_ANIM: 0x0c, // u8 slot, u8 mode (0 static,1 loop), u8 frame_or_fps + SPRITE_MOVE: 0x0d, // u8 slot, u8 ease, s16 x, s16 y, u16 frames — non-blocking + CONTROL: 0x0e, // u8 slot, s16 exit_x, u8 speed_q4 — blocking L/R walk + MASH: 0x0f, // u8 var, u16 target — blocking, A presses increment var + GOTO_SCENE: 0x10, // u8 scene + RASTER: 0x11, // u8 mode (RASTER_*), u8 amp_or_table + SFX: 0x12, // u8 id + COUNTER: 0x13, // u8 var, u8 show, s16 x, s16 y — OBJ digit HUD bound to var + AFFINE: 0x14, // u8 slot, u8 on — sprite uses affine matrix 0 (dbl-size) + LETTERBOX: 0x15, // u8 px, u16 frames — tween letterbox bar height + WORLD: 0x16, // (world scenes) blocking free roam; exit trigger pushes value + BREAKOUT: 0x17, // u8 rows, u8 lives, u16 budget frames — blocking; pushes bricks cleared + METER: 0x18, // u8 id, u8 var, s16 x, s16 y, u8 max, u8 show — HUD bar bound to var + WARP: 0x19, // u8 cx, u8 cy, u8 dir — reposition player on the grid + FACE: 0x1a, // u8 slot, u8 dir — set a walker sprite's facing row + WALK: 0x1b, // u8 slot, u8 cx, u8 cy — blocking scripted grid walk (x then y) + + PUSH: 0x20, // s16 + SET_VAR: 0x21, // u8 (pop) + GET_VAR: 0x22, // u8 (push) + ADD_VAR: 0x23, // u8, s16 (var += imm) + SET_FLAG: 0x24, // u8 + CLR_FLAG: 0x25, // u8 + GET_FLAG: 0x26, // u8 (push) + CMP: 0x27, // u8 kind (pop b, a; push a?b) + JZ: 0x28, // u16 (pop; jump if 0) + JMP: 0x29, // u16 + RND: 0x2a, // u8 n (push 0..n-1) + POP: 0x2b, +} as const; + +export const CMP_EQ = 0, CMP_NE = 1, CMP_LT = 2, CMP_GT = 3, CMP_LE = 4, CMP_GE = 5; + +export const FADE_IN_BLACK = 0; +export const FADE_OUT_BLACK = 1; +export const FADE_IN_WHITE = 2; +export const FADE_OUT_WHITE = 3; + +export const RASTER_OFF = 0; +export const RASTER_GRADIENT = 1; // per-line backdrop color from scene table +export const RASTER_WAVE_MAIN = 2; // per-line BG1 HOFS sine (amp tweenable) +export const RASTER_WAVE_FAR = 3; // per-line BG2 HOFS sine + +// caption styles +export const CAP_CHIP = 0; // top-left place/date chip (1 line) +export const CAP_SUB = 1; // bottom subtitle bar (<=2 lines) +export const CAP_CARD = 2; // centered title card (<=2 lines) +export const CAP_DIALOG = 3; // internal: dialog body (speaker chip + 2 lines) + +// sprite flags (SPRITE_SHOW) +export const SPR_HFLIP = 1; +export const SPR_SCREEN = 2; // screen-space (ignores camera) +export const SPR_BEHIND = 4; // prio 3 (behind main stage) +export const SPR_GHOST = 8; // OBJ semi-transparency (memory figures) + +// tween targets +export const TW = { + CAM_X: 0, + CAM_Y: 1, + BLDY: 2, // 0..16 + EVA: 3, // 0..16 (BG1 1st-target alpha) + EVB: 4, + MOSAIC: 5, // 0..15 + WAVE_AMP: 6, // raster sine amplitude, px + LETTERBOX: 7, // bar height px (0..32), windowed, raster-assisted + SHAKE: 8, // screen shake amplitude px + FAR_VX: 9, // far autoscroll, q8 px/frame + SKY_VX: 10, // sky autoscroll, q8 px/frame + OBJ_SCALE: 11, // affine matrix 0 scale, q8 (256 = 1.0) + OBJ_ANGLE: 12, // affine matrix 0 angle, 0..255 +} as const; +// sprite x/y tweens are encoded as 0x40 | slot<<1 | axis +export const TW_SPRITE_BASE = 0x40; + +export const EASE_LINEAR = 0, EASE_IN = 1, EASE_OUT = 2, EASE_INOUT = 3; + +export const SFX_BLIP = 0, SFX_CONFIRM = 1, SFX_WHOOSH = 2, SFX_STAR = 3; + +// waiting states (debug block `waiting`) +export const WAITING = { + RUN: 0, + A: 1, + DIALOG: 2, + CHOICE: 3, + CONTROL: 4, + MASH: 5, + FILM_DONE: 6, + BUSY: 7, // wait/fade/typewriter/scripted-walk + WORLD: 8, // free roam (OP_WORLD) + MINIGAME: 9, // breakout in flight +} as const; + +// --- text encoding (same scheme as aot cjk16) --------------------------------- +// 0x00 end, 0x0a newline, 0x20..0x7e ASCII (halfcell), 0x80|hi lo fullwidth +// glyph id. Glyph store = 95 baked ASCII halfcells + 2 halfcells per fullwidth +// glyph; each halfcell = two stacked 4bpp 8x8 tiles = 64 bytes, ink=UI_INK on 0. +export const TOK_END = 0x00; +export const TOK_NL = 0x0a; +export const ASCII_HALF = 95; // halfcells 0..94 = codepoints 0x20..0x7e +export const CAP_COLS = 26; // max text cells per caption line +export const CAP_LINES = 2; + +// --- debug block (EWRAM 0x02000000) -------------------------------------------- +export const DEBUG_ADDR = 0x02000000; +export const DBG_MAGIC = 0x41474153; // "SAGA" +export const DBG = { + MAGIC: 0x00, // u32 + BOOTED: 0x04, // u8 + SCENE: 0x05, // u8 + WAITING: 0x06, // u8 (WAITING.*) + LAST_CHOICE: 0x07, // s8 (-1 none) + FRAME: 0x08, // u16 scene-local frame + CUE_IP: 0x0a, // u16 + CAM_X: 0x0c, // s16 + CUR_TEXT: 0x0e, // u16 (last caption/dialog text id + 1; 0 = none) + TWEEN_MASK: 0x10, // u16 + CAPTION_BUSY: 0x12, // u8 + FILM_DONE: 0x13, // u8 + VARS: 0x14, // s16[N_VARS] + SPR0_X: 0x44, // s16 (sprite slot 0 world x — control assertions) + SPR0_Y: 0x46, // s16 + PLAYER_CX: 0x48, // u8 world-grid cell + PLAYER_CY: 0x49, // u8 + PLAYER_DIR: 0x4a, // u8 DIR_* + BRICKS: 0x4b, // u8 breakout bricks remaining + KIND: 0x4c, // u8 SCENE_* of current scene +} as const; + +// --- ByteWriter ---------------------------------------------------------------- +export class ByteWriter { + private buf: number[] = []; + get length(): number { + return this.buf.length; + } + u8(v: number): this { + this.buf.push(v & 0xff); + return this; + } + u16(v: number): this { + this.buf.push(v & 0xff, (v >> 8) & 0xff); + return this; + } + i16(v: number): this { + return this.u16(v & 0xffff); + } + u32(v: number): this { + this.buf.push(v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff); + return this; + } + bytes(b: ArrayLike): this { + for (let i = 0; i < b.length; i++) this.buf.push(b[i] & 0xff); + return this; + } + patchU16(at: number, v: number): this { + this.buf[at] = v & 0xff; + this.buf[at + 1] = (v >> 8) & 0xff; + return this; + } + toUint8Array(): Uint8Array { + return Uint8Array.from(this.buf); + } +} + +export function rgb555(r: number, g: number, b: number): number { + return ((r >> 3) & 31) | (((g >> 3) & 31) << 5) | (((b >> 3) & 31) << 10); +} + +export function hex555(hex: string): number { + const h = hex.replace("#", ""); + return rgb555(parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)); +} diff --git a/saga/test/crop.ts b/saga/test/crop.ts new file mode 100644 index 0000000..16e5b4b --- /dev/null +++ b/saga/test/crop.ts @@ -0,0 +1,22 @@ +// saga/test/crop.ts — crop + integer-scale a PNG region for close inspection. +// bun test/crop.ts [scale] +import { decodePng, encodePng } from "../compiler/png.ts"; + +const [path, xs, ys, ws, hs, ss] = process.argv.slice(2); +const img = decodePng(new Uint8Array(await Bun.file(path).arrayBuffer())); +const x0 = +xs, y0 = +ys, w = +ws, h = +hs, s = +(ss ?? 4); +const out = new Uint8Array(w * s * h * s * 4); +for (let y = 0; y < h * s; y++) + for (let x = 0; x < w * s; x++) { + const sx = x0 + Math.floor(x / s); + const sy = y0 + Math.floor(y / s); + const si = (sy * img.width + sx) * 4; + const di = (y * w * s + x) * 4; + out[di] = img.rgba[si]; + out[di + 1] = img.rgba[si + 1]; + out[di + 2] = img.rgba[si + 2]; + out[di + 3] = 255; + } +const dst = path.replace(/\.png$/, `.crop.png`); +await Bun.write(dst, encodePng(out, w * s, h * s)); +console.log(dst); diff --git a/saga/test/e2e.ts b/saga/test/e2e.ts new file mode 100644 index 0000000..e71ff4e --- /dev/null +++ b/saga/test/e2e.ts @@ -0,0 +1,387 @@ +// saga/test/e2e.ts — build REALITY DISTORTION and play it headlessly in mGBA, +// asserting the debug-block contract: title menu, world roaming + gates +// (workbench), the Breakout night, the sugared-water conviction battle, the +// Bandley -> Flint Center chain with the Mac's documented line, and the +// credits -> title loop. +// +// bun test/e2e.ts + +import { $ } from "bun"; +import { compileFilm } from "../compiler/index.ts"; +import { emitGenData } from "../compiler/emit.ts"; +import { buildRom } from "../compiler/rom.ts"; +import { DEBUG_ADDR, DBG, DBG_MAGIC, WAITING, SCENE_WORLD, SCENE_CINE } from "../spec/saga.ts"; + +const HERE = new URL(".", import.meta.url).pathname; +const ROOT = HERE + "../"; +const RUNNER = ROOT + "../aot/test/harness/mgba_runner"; +const ROM = ROOT + "dist/reality-distortion.gba"; +const SHOTS = ROOT + "dist/shots"; + +type Step = + | { op: "advance"; frames: number } + | { op: "press"; buttons: string[]; frames: number; release?: number } + | { op: "read"; name: string; addr: number; size: 1 | 2 | 4 } + | { op: "screenshot"; path: string }; + +const A = (n = 8): Step => ({ op: "press", buttons: ["A"], frames: 1, release: n }); +const DOWN: Step = { op: "press", buttons: ["DOWN"], frames: 1, release: 6 }; +const hold = (b: string, frames: number): Step => ({ op: "press", buttons: [b], frames, release: 4 }); +const adv = (frames: number): Step => ({ op: "advance", frames }); +const rd = (name: string, off: number, size: 1 | 2 | 4 = 1): Step => ({ op: "read", name, addr: DEBUG_ADDR + off, size }); +const shot = (n: string): Step => ({ op: "screenshot", path: `${SHOTS}/${n}.ppm` }); + +async function run(steps: Step[]): Promise> { + const scenario = ROOT + "dist/e2e-scenario.json"; + await Bun.write(scenario, JSON.stringify({ steps })); + const out = await $`${RUNNER} ${ROM} ${scenario}`.text(); + const line = out.trim().split("\n").reverse().find((l) => l.trim().startsWith("{")); + if (!line) throw new Error("runner produced no JSON:\n" + out); + const parsed = JSON.parse(line); + if (!parsed.ok) throw new Error("runner error: " + JSON.stringify(parsed)); + return parsed.reads ?? {}; +} + +let passed = 0; +let failed = 0; +function check(name: string, got: unknown, want: unknown): void { + const ok = got === want; + console.log(` ${ok ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}: got ${got}${ok ? "" : `, want ${want}`}`); + ok ? passed++ : failed++; +} +function checkTrue(name: string, got: boolean, detail = ""): void { + console.log(` ${got ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}${detail ? `: ${detail}` : ""}`); + got ? passed++ : failed++; +} + +// title menu: boot -> Play (A) or a chapter pick +const boot: Step[] = [adv(260)]; +const play: Step[] = [...boot, A(), adv(40)]; +const chapter = (page2: boolean, index: number): Step[] => { + const s: Step[] = [...boot, DOWN, A(), adv(10)]; + const downs = page2 ? 4 : index; + for (let i = 0; i < downs; i++) s.push(DOWN); + s.push(A(), adv(10)); + if (page2) { + for (let i = 0; i < index; i++) s.push(DOWN); + s.push(A(), adv(10)); + } + s.push(adv(60)); + return s; +}; + +async function main(): Promise { + console.log("Building REALITY DISTORTION..."); + const film = await compileFilm(ROOT + "game/reality-distortion.ts"); + const rom = await buildRom(emitGenData(film), ROM, "REALDISTORT"); + await $`mkdir -p ${SHOTS}`.quiet(); + console.log(`ROM: ${rom.size} bytes, ${film.scenes.length} scenes, ${film.debug.texts.length} texts\n`); + + const sc = film.debug.sceneIds; + const varAddr = (name: string): number => { + const idx = film.debug.vars[name]; + if (idx === undefined) throw new Error("unknown var " + name); + return DBG.VARS + idx * 2; + }; + const tid = (s: string): number => { + const i = film.debug.texts.indexOf(s); + if (i < 0) throw new Error("text not found: " + s); + return i; + }; + + console.log("Scenario 1 — boot, title menu, workbench world + gate"); + { + const r = await run([ + adv(80), + rd("magic", DBG.MAGIC, 4), + rd("booted", DBG.BOOTED), + rd("scene0", DBG.SCENE), + adv(180), + rd("menu_wait", DBG.WAITING), + shot("rd_title"), + A(), // Play + adv(120), + rd("scene1", DBG.SCENE), + rd("kind1", DBG.KIND), + rd("waiting_world", DBG.WAITING), + rd("cx0", DBG.PLAYER_CX), + rd("cy0", DBG.PLAYER_CY), + shot("rd_garage62"), + // try to leave without the bench: down-left to the door + hold("LEFT", 80), + hold("DOWN", 60), + adv(30), // gate dialog appears (DAD: not yet) + rd("gate_dialog", DBG.WAITING), + A(), + adv(20), + // now do it right: to the dad, then the bench + hold("UP", 20), + hold("RIGHT", 20), + hold("UP", 40), // ends around (3..4, 8) area facing up + adv(10), + // walk to a bench-facing cell: left along row 8 + hold("LEFT", 60), + hold("UP", 20), + A(), // bench caption + adv(60), + rd("bench_wait", DBG.WAITING), + A(), // dismiss caption -> dad dialogs + adv(60), + A(), + adv(60), + A(), + adv(20), + rd("back_world", DBG.WAITING), + shot("rd_bench"), + // leave through the door (bottom-left) + hold("DOWN", 80), + hold("LEFT", 80), + hold("DOWN", 40), + adv(80), + rd("scene2", DBG.SCENE), + ]); + check("debug magic 'SAGA'", r.magic >>> 0, DBG_MAGIC); + check("booted", r.booted, 1); + check("boots into title", r.scene0, sc.title); + check("title menu is a choice", r.menu_wait, WAITING.CHOICE); + check("Play -> workbench", r.scene1, sc.garage62); + check("workbench is a WORLD scene", r.kind1, SCENE_WORLD); + check("roaming", r.waiting_world, WAITING.WORLD); + check("kid starts at cx=10", r.cx0, 10); + check("kid starts at cy=9", r.cy0, 9); + check("door is gated (dad speaks)", r.gate_dialog, WAITING.DIALOG); + check("bench caption waits for A", r.bench_wait, WAITING.A); + check("bench cue returns to roam", r.back_world, WAITING.WORLD); + check("door now exits to the call", r.scene2, sc.hewlett); + } + + console.log("Scenario 2 — chapter: Breakout night at Atari"); + { + const r = await run([ + ...chapter(false, 3), + rd("scene", DBG.SCENE), + adv(120), + A(), // boss dialog + adv(60), + A(), // woz dialog + adv(60), + A(), // instructions caption + adv(30), + rd("mini_wait", DBG.WAITING), + rd("bricks0", DBG.BRICKS), + shot("rd_breakout"), + A(), // launch + adv(600), + rd("bricks_mid", DBG.BRICKS), + adv(3200), // budget expires + rd("after", DBG.WAITING), + rd("cleared", varAddr("bricks"), 2), + ]); + check("chapter menu -> atari", r.scene, sc.atari); + check("breakout running", r.mini_wait, WAITING.MINIGAME); + check("4 rows x 12 bricks", r.bricks0, 48); + checkTrue("bricks fell", r.bricks_mid < 48, `bricks_mid=${r.bricks_mid}`); + checkTrue("night ended", r.after !== WAITING.MINIGAME, `waiting=${r.after}`); + checkTrue("cleared recorded", r.cleared >= 1, `cleared=${r.cleared}`); + } + + console.log("Scenario 3 — chapter: Sugared Water conviction battle"); + { + const r = await run([ + ...chapter(true, 2), + rd("scene", DBG.SCENE), + adv(160), + A(), // "penthouse" caption + adv(90), + A(), // Sculley opener + adv(40), + rd("battle_wait", DBG.WAITING), + rd("conv0", varAddr("conv"), 2), + shot("rd_sculley"), + // Paint the future x3 (2 -> 4 -> 6), then The question + DOWN, A(), adv(80), A(), adv(30), + DOWN, A(), adv(80), A(), adv(60), A(), adv(30), + rd("conv_mid", varAddr("conv"), 2), + DOWN, DOWN, A(), adv(80), A(), adv(60), A(), adv(60), A(), adv(30), + rd("conv_end", varAddr("conv"), 2), + adv(60), + A(), // "..." + adv(40), + A(), // dangerous + adv(240), // chip + white fade out + rd("scene_after", DBG.SCENE), + ]); + check("chapter menu -> sculley", r.scene, sc.sculley); + check("battle is a choice loop", r.battle_wait, WAITING.CHOICE); + check("conviction starts at 2", r.conv0, 2); + checkTrue("future pitches build conviction", r.conv_mid >= 4, `conv=${r.conv_mid}`); + check("the question lands at 8", r.conv_end, 8); + check("white fade chains into bandley", r.scene_after, sc.bandley); + } + + console.log("Scenario 4 — Bandley 3 world -> Flint Center: the Mac speaks"); + { + const r = await run([ + ...chapter(true, 3), + rd("scene", DBG.SCENE), + rd("kind", DBG.KIND), + adv(30), + // to the mac desk: up to row 7, right until the desk stops us + hold("UP", 40), + hold("RIGHT", 80), + adv(10), + A(), // prototype caption + adv(70), + A(), // dismiss caption -> dialog starts typing + adv(60), + A(), // dismiss "Apple II is the past" + adv(20), + shot("rd_bandley"), + // to the door: left along row 7, up to row 5, right to the door column, up + hold("LEFT", 100), + hold("UP", 8), // exactly one step up, onto row 5 (row 4 has a plant) + hold("RIGHT", 60), + hold("UP", 20), + adv(80), + rd("scene_keynote", DBG.SCENE), + rd("kind_keynote", DBG.KIND), + adv(200), + A(), // "January 24" caption + adv(60), + A(), // bow tie caption + adv(60), + A(6), A(6), A(6), A(6), A(6), A(6), A(6), A(6), A(6), A(6), + A(6), A(6), A(6), A(6), A(6), A(6), A(6), // applause mash (15 + spares) + adv(60), + A(), // canvas bag caption + adv(120), + A(), // chariots caption + adv(60), + A(), // hello I am macintosh + adv(60), + A(), // out of that bag + adv(60), + rd("mac_line", DBG.CUR_TEXT, 2), + shot("rd_keynote"), + A(), // never trust + adv(80), + A(), A(6), + adv(60), + A(), + adv(60), + A(), // steve jobs! + adv(120), + A(), // thunder caption + adv(60), + A(), // tears caption + adv(300), // zoom + white fade + rd("scene_credits", DBG.SCENE), + ]); + check("chapter menu -> bandley", r.scene, sc.bandley); + check("bandley is a WORLD scene", r.kind, SCENE_WORLD); + check("door exits into the keynote", r.scene_keynote, sc.keynote); + check("keynote is cinematic", r.kind_keynote, SCENE_CINE); + check( + "the Mac's documented line is on deck", + r.mac_line, + tid("Never trust a computer\nthat you can't lift!") + 1, + ); + check("white-out chains into credits", r.scene_credits, sc.credits); + } + + console.log("Scenario 5 — credits loop back to the title"); + { + const r = await run([ + ...chapter(true, 3), + // skip through bandley quickly: mac desk then door (same route) + hold("UP", 40), + hold("RIGHT", 80), + adv(10), + A(), adv(70), A(), adv(60), A(), adv(20), + hold("LEFT", 100), + hold("UP", 8), // exactly one step up, onto row 5 (row 4 has a plant) + hold("RIGHT", 60), + hold("UP", 20), + adv(280), + A(), adv(60), A(), adv(60), + A(6), A(6), A(6), A(6), A(6), A(6), A(6), A(6), A(6), A(6), + A(6), A(6), A(6), A(6), A(6), A(6), A(6), + adv(60), A(), adv(120), A(), adv(60), + A(), adv(60), A(), adv(60), A(), adv(80), A(), A(6), adv(60), A(), adv(60), A(), + adv(120), A(), adv(60), A(), adv(300), + rd("scene_credits", DBG.SCENE), + adv(1100), // credit cards tick by + rd("back_title", DBG.SCENE), + rd("menu_again", DBG.WAITING), + shot("rd_credits_loop"), + ]); + check("reached credits", r.scene_credits, sc.credits); + check("credits loop to the title", r.back_title, sc.title); + checkTrue( + "title menu is live again", + r.menu_again === WAITING.CHOICE || r.menu_again === WAITING.BUSY, + `waiting=${r.menu_again}`, + ); + } + + console.log("Scenario 6 — Fifty Boards: woz branch-talk, supplier meter battle, delivery"); + { + const r = await run([ + ...chapter(true, 0), + rd("scene", DBG.SCENE), + adv(60), A(), adv(60), A(), adv(60), A(), adv(30), // intro captions + rd("roam", DBG.WAITING), + // woz stands at (3,7): up to the walkway, left until he blocks us + hold("UP", 24), + hold("LEFT", 80), + A(), // talk (his cue starts with an if — the blob-absolute jump test) + adv(70), + rd("woz_dialog", DBG.WAITING), + A(), // dismiss line 1 + adv(60), + A(), // dismiss line 2 + adv(20), + rd("woz_done", DBG.WAITING), + // phone on the wall at (7,3..5): right to (7,7), up to (7,6), face up + hold("RIGHT", 24), + hold("UP", 8), + hold("UP", 6), + A(), // examine -> parts man caption + adv(70), + A(), // dismiss -> encounter opens + adv(40), + rd("battle", DBG.WAITING), + shot("rd_supplier"), + A(), // Mention the order + adv(70), A(), adv(60), A(), adv(30), + rd("trust1", varAddr("trust"), 2), + DOWN, A(), // Promise net thirty + adv(70), A(), adv(30), + DOWN, A(), // and again -> trust >= 8 + adv(70), A(), adv(40), + A(), // supplier: net thirty (closing dialog) + adv(60), A(), adv(60), // parts-on-credit caption + rd("credit", varAddr("credit"), 2), + // deliver: to the door at (8..10, 5) — exactly three steps right + hold("RIGHT", 20), + hold("UP", 20), + adv(140), // DAY 29 card + A(), // apple I caption + adv(120), + rd("scene_after", DBG.SCENE), + ]); + check("chapter menu -> garage76", r.scene, sc.garage76); + check("intro ends in roam", r.roam, WAITING.WORLD); + check("woz talk opens (if-branch sub-cue)", r.woz_dialog, WAITING.DIALOG); + check("woz talk returns to roam", r.woz_done, WAITING.WORLD); + check("supplier battle is a choice loop", r.battle, WAITING.CHOICE); + checkTrue("the order builds trust", r.trust1 >= 5, `trust=${r.trust1}`); + check("net-thirty closes the deal", r.credit, 1); + check("delivery chains into the faire", r.scene_after, sc.faire); + } + + console.log(`\n${failed === 0 ? "\x1b[32m" : "\x1b[31m"}${passed} passed, ${failed} failed\x1b[0m`); + process.exit(failed === 0 ? 0 : 1); +} + +await main(); diff --git a/saga/test/engine-e2e.ts b/saga/test/engine-e2e.ts new file mode 100644 index 0000000..ad1598d --- /dev/null +++ b/saga/test/engine-e2e.ts @@ -0,0 +1,167 @@ +// saga/test/engine-e2e.ts — headless engine test for the NEW saga paths: +// world scenes (grid walk, collision, NPC talk, examine spot, exit trigger) +// and the breakout minigame + meters. Uses the smoke film (placeholder art). +// +// bun test/engine-e2e.ts + +import { $ } from "bun"; +import { compileFilm } from "../compiler/index.ts"; +import { emitGenData } from "../compiler/emit.ts"; +import { buildRom } from "../compiler/rom.ts"; +import { DEBUG_ADDR, DBG, DBG_MAGIC, WAITING, SCENE_WORLD, DIR_UP, DIR_DOWN } from "../spec/saga.ts"; + +const HERE = new URL(".", import.meta.url).pathname; +const ROOT = HERE + "../"; +const RUNNER = ROOT + "../aot/test/harness/mgba_runner"; +const ROM = ROOT + "dist/smoke.gba"; +const SHOTS = ROOT + "dist/shots"; + +type Step = + | { op: "advance"; frames: number } + | { op: "press"; buttons: string[]; frames: number; release?: number } + | { op: "read"; name: string; addr: number; size: 1 | 2 | 4 } + | { op: "screenshot"; path: string }; + +const A = (n = 8): Step => ({ op: "press", buttons: ["A"], frames: 1, release: n }); +const hold = (b: string, frames: number): Step => ({ op: "press", buttons: [b], frames, release: 4 }); +const adv = (frames: number): Step => ({ op: "advance", frames }); +const rd = (name: string, off: number, size: 1 | 2 | 4 = 1): Step => ({ op: "read", name, addr: DEBUG_ADDR + off, size }); +const shot = (n: string): Step => ({ op: "screenshot", path: `${SHOTS}/${n}.ppm` }); + +async function run(steps: Step[]): Promise> { + const scenario = ROOT + "dist/engine-e2e-scenario.json"; + await Bun.write(scenario, JSON.stringify({ steps })); + const out = await $`${RUNNER} ${ROM} ${scenario}`.text(); + const line = out.trim().split("\n").reverse().find((l) => l.trim().startsWith("{")); + if (!line) throw new Error("runner produced no JSON:\n" + out); + const parsed = JSON.parse(line); + if (!parsed.ok) throw new Error("runner error: " + JSON.stringify(parsed)); + return parsed.reads ?? {}; +} + +let passed = 0; +let failed = 0; +function check(name: string, got: unknown, want: unknown): void { + const ok = got === want; + console.log(` ${ok ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}: got ${got}${ok ? "" : `, want ${want}`}`); + ok ? passed++ : failed++; +} +function checkTrue(name: string, got: boolean, detail = ""): void { + console.log(` ${got ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}${detail ? `: ${detail}` : ""}`); + got ? passed++ : failed++; +} + +async function main(): Promise { + console.log("Building smoke film..."); + const film = await compileFilm(ROOT + "test/smoke-film.ts"); + const rom = await buildRom(emitGenData(film), ROM, "SAGASMOKE"); + await $`mkdir -p ${SHOTS}`.quiet(); + console.log(`ROM: ${rom.size} bytes\n`); + + const sc = film.debug.sceneIds; + const varAddr = (name: string): number => { + const idx = film.debug.vars[name]; + if (idx === undefined) throw new Error("unknown var " + name); + return DBG.VARS + idx * 2; + }; + + console.log("Scenario 1 — world scene: roam, collision, NPC, spot, exit"); + { + const r = await run([ + adv(90), // fade in + chip caption + OP_WORLD + rd("magic", DBG.MAGIC, 4), + rd("kind", DBG.KIND), + rd("waiting_world", DBG.WAITING), + rd("cx0", DBG.PLAYER_CX), + rd("cy0", DBG.PLAYER_CY), + shot("engine_world"), + // walk up 4 cells; the NPC on (10,2) blocks the 5th so we stop at (10,3) + hold("UP", 70), + rd("cy_up", DBG.PLAYER_CY), + rd("dir_up", DBG.PLAYER_DIR), + // talk to the NPC (facing up) + A(), + adv(90), // dialog typing + rd("waiting_dialog", DBG.WAITING), + shot("engine_npc"), + A(), // dismiss + adv(12), + rd("talked", varAddr("talked"), 2), + rd("back_to_world", DBG.WAITING), + // to the bench row, then walk left until the solid bench stops us at (5,4) + hold("DOWN", 8), // exactly one step -> (10,4) + hold("LEFT", 70), // 5 steps then blocked by the bench + rd("cx_left", DBG.PLAYER_CX), + rd("cy_row", DBG.PLAYER_CY), + rd("dir_left", DBG.PLAYER_DIR), + A(), // examine the bench (facing (4,4)) + adv(70), + rd("waiting_spot", DBG.WAITING), + A(), + adv(12), + rd("benched", varAddr("benched"), 2), + // to the door: DOWN to the bottom wall (5,13), then RIGHT across the exit + hold("DOWN", 200), + hold("RIGHT", 100), // steps onto (10,13) mid-hold -> exit fires + adv(40), // captionClear + setVar + fadeOut + rd("exit_code", varAddr("exit_code"), 2), + adv(40), + rd("scene_next", DBG.SCENE), + ]); + check("debug magic 'SAGA'", r.magic >>> 0, DBG_MAGIC); + check("scene kind is WORLD", r.kind, SCENE_WORLD); + check("roaming (WAITING_WORLD)", r.waiting_world, WAITING.WORLD); + check("player starts at cx=10", r.cx0, 10); + check("player starts at cy=7", r.cy0, 7); + check("NPC blocks at cy=3", r.cy_up, 3); + check("facing up", r.dir_up, DIR_UP); + check("NPC talk opens dialog", r.waiting_dialog, WAITING.DIALOG); + check("talk cue ran (talked=1)", r.talked, 1); + check("dialog returns to roam", r.back_to_world, WAITING.WORLD); + check("bench blocks at cx=5", r.cx_left, 5); + check("on the bench row (cy=4)", r.cy_row, 4); + check("facing left", r.dir_left, 2); + check("bench spot waits for A", r.waiting_spot, WAITING.A); + check("spot cue ran (benched=1)", r.benched, 1); + check("door exit pushes its value", r.exit_code, 7); + check("exit chains into arcade", r.scene_next, sc.arcade); + } + + console.log("Scenario 2 — breakout: launch, bricks fall, budget end, meter"); + { + const r = await run([ + adv(90), // room fadein+caption grace (scene 0 boots first) + // replay room quickly: straight to the door + hold("DOWN", 130), + adv(60), + rd("scene_arcade", DBG.SCENE), + adv(60), // arcade fade + caption + meter + OP_BREAKOUT + rd("waiting_minigame", DBG.WAITING), + rd("bricks0", DBG.BRICKS), + shot("engine_breakout0"), + A(), // launch + adv(240), + rd("bricks_mid", DBG.BRICKS), + shot("engine_breakout1"), + adv(260), // budget (420) expires + rd("after_game", DBG.WAITING), + rd("cleared", varAddr("cleared"), 2), + adv(40), + A(), // dismiss dialog + adv(60), + rd("scene_after", DBG.SCENE), + ]); + check("door column exits the room", r.scene_arcade, sc.arcade); + check("breakout running (MINIGAME)", r.waiting_minigame, WAITING.MINIGAME); + check("3 rows x 12 bricks", r.bricks0, 36); + checkTrue("ball cleared some bricks", r.bricks_mid < 36, `bricks_mid=${r.bricks_mid}`); + checkTrue("budget ended the game", r.after_game !== WAITING.MINIGAME, `waiting=${r.after_game}`); + checkTrue("cleared count recorded", r.cleared >= 1 && r.cleared <= 36, `cleared=${r.cleared}`); + check("arcade chains into street", r.scene_after, sc.street); + } + + console.log(`\n${failed === 0 ? "\x1b[32m" : "\x1b[31m"}${passed} passed, ${failed} failed\x1b[0m`); + process.exit(failed === 0 ? 0 : 1); +} + +await main(); diff --git a/saga/test/gen-placeholder-art.ts b/saga/test/gen-placeholder-art.ts new file mode 100644 index 0000000..8eae35f --- /dev/null +++ b/saga/test/gen-placeholder-art.ts @@ -0,0 +1,152 @@ +// saga/test/gen-placeholder-art.ts — procedural PNGs for the smoke film so the +// pipeline is testable without PixelLab. + +import { encodePng } from "../compiler/png.ts"; + +const DIR = new URL("./art/", import.meta.url).pathname; + +function img(w: number, h: number, fn: (x: number, y: number) => [number, number, number, number]): Uint8Array { + const rgba = new Uint8Array(w * h * 4); + for (let y = 0; y < h; y++) + for (let x = 0; x < w; x++) { + const [r, g, b, a] = fn(x, y); + const i = (y * w + x) * 4; + rgba[i] = r; + rgba[i + 1] = g; + rgba[i + 2] = b; + rgba[i + 3] = a; + } + return encodePng(rgba, w, h); +} + +// main stage: 384x160 "street": ground + building blocks + lamp posts +await Bun.write( + DIR + "street.png", + img(384, 160, (x, y) => { + if (y > 128) return [70, 60, 56, 255]; // ground + if (y > 124) return [110, 100, 90, 255]; // curb + const block = Math.floor(x / 48); + const inBuilding = y > 40 + (block % 3) * 16 && x % 48 < 40; + if (inBuilding) { + const win = x % 8 < 4 && y % 12 < 6 && y > 56; + return win ? [230, 200, 120, 255] : [40 + (block % 4) * 12, 44, 60 + (block % 3) * 10, 255]; + } + return [0, 0, 0, 0]; // transparent -> sky shows + }), +); + +// far layer: rolling hill silhouettes (transparent above) +await Bun.write( + DIR + "hills.png", + img(240, 160, (x, y) => { + const ridge = 90 + Math.round(18 * Math.sin(x / 25) + 8 * Math.sin(x / 7)); + return y > ridge ? [24, 34, 52, 255] : [0, 0, 0, 0]; + }), +); + +// walker sprite: 32x32, 2 frames side by side +await Bun.write( + DIR + "walker.png", + img(64, 32, (x, y) => { + const f = x >= 32 ? 1 : 0; + const lx = x % 32; + // head + if (Math.hypot(lx - 16, y - 8) < 5) return [240, 200, 160, 255]; + // body + if (y >= 13 && y < 24 && lx >= 12 && lx < 20) return [66, 184, 131, 255]; + // legs alternate by frame + if (y >= 24 && y < 30) { + const spread = f ? 3 : 1; + if (Math.abs(lx - (16 - spread)) < 2 || Math.abs(lx - (16 + spread)) < 2) return [40, 48, 60, 255]; + } + return [0, 0, 0, 0]; + }), +); + +// emblem: 32x32 V mark +await Bun.write( + DIR + "emblem.png", + img(32, 32, (x, y) => { + const d1 = Math.abs(x - 6 - y * 0.4); + const d2 = Math.abs(x - 26 + y * 0.4); + if (y < 26 && (d1 < 2.5 || d2 < 2.5)) return [66, 184, 131, 255]; + if (y < 26 && (d1 < 4 || d2 < 4)) return [53, 73, 94, 255]; + return [0, 0, 0, 0]; + }), +); + +// world room: 320x240 (20x15 cells), walls on the border + a 2-cell bench +const ROOM_GRID = [ + "####################", + "#..................#", + "#.........w........#", + "#..................#", + "#..##..............#", + "#..................#", + "#..................#", + "#.........p........#", + "#..................#", + "#..................#", + "#..................#", + "#..................#", + "#..................#", + "#.........d........#", + "####################", +]; +await Bun.write( + DIR + "room.png", + img(320, 240, (x, y) => { + const cx = Math.floor(x / 16); + const cy = Math.floor(y / 16); + const ch = ROOM_GRID[cy]?.[cx] ?? "#"; + if (ch === "#") { + const brick = (cx + cy) % 2 === 0; + return brick ? [86, 60, 44, 255] : [70, 48, 36, 255]; + } + if (ch === "d") return [140, 110, 60, 255]; // doormat + const check = (cx + cy) % 2 === 0; + const edge = x % 16 === 0 || y % 16 === 0; + if (edge) return [52, 56, 68, 255]; + return check ? [66, 72, 86, 255] : [60, 66, 80, 255]; + }), +); + +// walker sheets: 16x32 x 6 frames (down x2, up x2, side x2) +function walkerSheet(body: [number, number, number]): Uint8Array { + return img(96, 32, (x, y) => { + const f = Math.floor(x / 16); // 0..5 + const row = Math.floor(f / 2); // 0 down, 1 up, 2 side + const ph = f % 2; + const lx = x % 16; + // head + if (Math.hypot(lx - 8, y - 9) < 5) { + // face features by direction + if (row === 0 && y >= 8 && y <= 10 && (lx === 6 || lx === 10)) return [20, 20, 30, 255]; + if (row === 2 && y >= 8 && y <= 10 && lx === 11) return [20, 20, 30, 255]; + return [240, 200, 160, 255]; + } + // body + if (y >= 14 && y < 25 && lx >= 4 && lx < 12) return [...body, 255] as [number, number, number, number]; + // legs alternate + if (y >= 25 && y < 31) { + const spread = ph ? 3 : 1; + if (Math.abs(lx - (8 - spread)) < 2 || Math.abs(lx - (8 + spread)) < 2) return [40, 48, 60, 255]; + } + return [0, 0, 0, 0]; + }); +} +await Bun.write(DIR + "hero.png", walkerSheet([66, 184, 131])); +await Bun.write(DIR + "buddy.png", walkerSheet([214, 130, 60])); + +// breakout court: 240x160 dark hall with side rails +await Bun.write( + DIR + "court.png", + img(240, 160, (x, y) => { + if (x < 20 || x >= 220) return [30, 34, 52, 255]; + if (y < 12) return [30, 34, 52, 255]; + const g = 18 + Math.floor((y / 160) * 14); + return [g - 8, g - 4, g + 10, 255]; + }), +); + +console.log("placeholder art written to saga/test/art/"); diff --git a/saga/test/ppm2png.ts b/saga/test/ppm2png.ts new file mode 100644 index 0000000..24e689e --- /dev/null +++ b/saga/test/ppm2png.ts @@ -0,0 +1,34 @@ +// saga/test/ppm2png.ts — convert the harness's P6 PPM screenshots to PNG. +// bun test/ppm2png.ts dist/shots/*.ppm +import { encodePng } from "../compiler/png.ts"; + +export async function ppm2png(path: string): Promise { + const bytes = new Uint8Array(await Bun.file(path).arrayBuffer()); + // P6\n \n255\n + let o = 0; + const token = (): string => { + while (bytes[o] === 32 || bytes[o] === 10 || bytes[o] === 13 || bytes[o] === 9) o++; + let s = ""; + while (o < bytes.length && ![32, 10, 13, 9].includes(bytes[o])) s += String.fromCharCode(bytes[o++]); + return s; + }; + if (token() !== "P6") throw new Error("not P6: " + path); + const w = parseInt(token()); + const h = parseInt(token()); + token(); // maxval + o++; // single whitespace after maxval + const rgba = new Uint8Array(w * h * 4); + for (let i = 0; i < w * h; i++) { + rgba[i * 4] = bytes[o + i * 3]; + rgba[i * 4 + 1] = bytes[o + i * 3 + 1]; + rgba[i * 4 + 2] = bytes[o + i * 3 + 2]; + rgba[i * 4 + 3] = 255; + } + const out = path.replace(/\.ppm$/, ".png"); + await Bun.write(out, encodePng(rgba, w, h)); + return out; +} + +if (import.meta.main) { + for (const p of process.argv.slice(2)) console.log(await ppm2png(p)); +} diff --git a/saga/test/smoke-film.ts b/saga/test/smoke-film.ts new file mode 100644 index 0000000..53e88fc --- /dev/null +++ b/saga/test/smoke-film.ts @@ -0,0 +1,174 @@ +// saga/test/smoke-film.ts — 2-scene pipeline smoke test (placeholder art). +// Exercises: gradient raster, parallax pan, sprite walk, captions (CJK+ASCII), +// dialog, choice branch, control walk, mash+counter, letterbox, mosaic, fades, +// affine zoom/spin, wave raster, scene transition, gotoScene loop guard. + +import { + defineFilm, defineScene, cue, image, gradient, sprite, + fadeIn, fadeOut, wait, waitA, waitTweens, caption, captionClear, dialog, choice, + pan, letterbox, mosaicTo, shake, alpha, zoom, spinTo, show, hide, animate, + moveTo, walkTo, control, mash, counter, affineOn, sfx, gotoScene, setFlag, hasFlag, + rasterWave, rasterOff, world, breakout, meterShow, meterHide, setVar, varEq, walk, face, +} from "@pocketjs/saga"; + +const street = defineScene({ + id: "street", + sky: gradient("#0a1430", "#2a4a7a", "#e8965a"), + far: image("art/hills.png", { scroll: 0.35 }), + main: image("art/street.png", { wide: true }), + actors: { + walker: sprite("art/walker.png", { w: 32, h: 32, frames: 2, fps: 8, at: [60, 100] }), + }, + play: cue(function* () { + yield letterbox(16, 1); + yield fadeIn(30); + yield caption("chip", "1990年代 · 某条街"); + yield wait(20); + yield show("walker"); + yield letterbox(0, 30); + yield pan(144, 120, "inout"); + yield walkTo("walker", 240, 120); + yield caption("sub", "他沿着街走。Hello GBA!"); + yield waitA(); + yield captionClear("all"); + yield dialog("路人", "要不要自己走一段?"); + const c = yield choice(["好啊", "算了"]); + if (c === 0) { + yield setFlag("walked"); + yield control("walker", 330, 1.5); + } else { + yield walkTo("walker", 330, 90); + } + yield caption("sub", "按 A 收集星星!"); + yield counter("stars", 200, 24); + yield mash("stars", 5); + yield captionClear("all"); + yield shake(3, 40); + yield mosaicTo(12, 40); + yield fadeOut(30); + }), +}); + +const dream = defineScene({ + id: "dream", + sky: gradient("#050510", "#1a1035"), + backdrop: "#050510", + wave: { layer: "main", amp: 3 }, + main: image("art/hills.png"), + actors: { + emblem: sprite("art/emblem.png", { w: 32, h: 32, at: [120, 70], screen: true }), + }, + play: cue(function* () { + yield fadeIn(40); + yield caption("card", "梦境 DREAM"); + yield wait(30); + yield show("emblem", 112, 60); + yield affineOn("emblem"); + yield zoom(0.3, 1); + yield zoom(1.5, 60, "out"); + yield spinTo(360, 90, "inout"); + yield waitTweens(); + yield rasterOff(); + yield alpha(8, 8, 40); + yield waitA(); + if (yield hasFlag("walked")) { + yield caption("sub", "你走过了那条街。"); + } else { + yield caption("sub", "旁观也是一种走法。"); + } + yield waitA(); + yield captionClear("all"); + yield fadeOut(30); + }), +}); + +// world scene: grid walking, NPC talk, examine spot, exit door +const room = defineScene({ + id: "room", + main: image("art/room.png"), + backdrop: "#101018", + actors: { + hero: sprite("art/hero.png", { w: 16, h: 32, frames: 6, walkFpd: 2 }), + buddy: sprite("art/buddy.png", { w: 16, h: 32, frames: 6, walkFpd: 2 }), + }, + world: { + grid: [ + "####################", + "#..................#", + "#.........w........#", + "#..................#", + "#..##..............#", + "#..................#", + "#..................#", + "#.........p........#", + "#..................#", + "#..................#", + "#..................#", + "#..................#", + "#..................#", + "#.........d........#", + "####################", + ], + player: { actor: "hero", at: "p", dir: "down" }, + npcs: { + buddy: { + actor: "buddy", + at: "w", + dir: "down", + talk: cue(function* () { + // the branch matters: sub-cue jump targets must be blob-absolute + if (yield varEq("talked", 1)) { + yield dialog("BUDDY", "Again? Go on, then."); + } else { + yield dialog("BUDDY", "Grid walking works.\nBench, then the door."); + yield setVar("talked", 1); + } + }), + }, + }, + spots: { + bench: { + at: [3, 4, 2, 1], + run: cue(function* () { + yield caption("sub", "A sturdy workbench."); + yield waitA(); + yield captionClear("all"); + yield setVar("benched", 1); + }), + }, + }, + exits: { door: { at: "d", value: 7 } }, + }, + play: cue(function* () { + yield fadeIn(20); + yield caption("chip", "WORLD TEST"); + const exit = yield world(); + yield captionClear("all"); + yield setVar("exit_code", exit); + yield fadeOut(20); + }), +}); + +// breakout + meter scene +const arcade = defineScene({ + id: "arcade", + main: image("art/court.png"), + backdrop: "#0a0c14", + play: cue(function* () { + yield fadeIn(15); + yield caption("sub", "BREAKOUT — A to launch"); + yield setVar("mood", 6); + yield meterShow(0, "mood", 24, 4, 8); + const cleared = yield breakout(3, 2, 420); + yield setVar("cleared", cleared); + yield meterHide(0); + yield captionClear("all"); + yield dialog("SMOKE", "Night over. Bricks counted."); + yield fadeOut(15); + yield gotoScene("street"); + }), +}); + +// room first: the engine e2e drives the new world/minigame paths with the +// shortest possible boot; street/dream keep covering the cine vocabulary. +export default defineFilm({ title: "SAGA SMOKE", scenes: [room, arcade, street, dream] });