From 7ed7de0d1004dfc629f4ecd4ff21bdcd163ecae6 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:21:49 +0800 Subject: [PATCH 1/5] feat(mon): port the Gen1 recomp architecture to a PocketJS runtime on PSP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports bryanthaboi/pokemon-gen1-recomp-project — a 44 kLOC Lua/LÖVE2D recreation of a Gen 1 creature RPG — into the PocketJS ecosystem as its own specialized runtime, following the Cores + Surfaces + Guest pattern docs/RUNTIMES.md lays out, and ships it running on a PSP. What is ported is that project's engine decomposition and its documented Gen-1 behavioural rules. What is deliberately NOT ported is its content path: the upstream project reconstructs everything by decoding a player-supplied Game Boy ROM, and this runtime has no counterpart to that layer, no code that can read one, and a test that keeps it that way (docs/MON.md §1). The game that ships instead is SPARKWOOD — original creatures, world, words and art, all authored in TypeScript. engine/pocketmon/crates/pocketmon-core the RPG core: grid world with the bottom-left-tile collision rule and stitched map connections, a full turn-battle engine (damage/crit/accuracy, type chart, 30 move effects, status, catching, experience, trainer AI), a resumable 41-verb script VM, a checksummed binary save, the text engine, the scene builder, and the `mon` surface's op dispatcher. no_std + alloc, zero dependencies, zero floating point. engine/pocketmon/crates/pocketmon-gu the GE backend for its draw list engine/pocketmon/crates/pocketmon-psp the EBOOT: arena allocator, frame loop, input mapping, and a capture build for the goldens engine/pocketmon/crates/pocketmon-sim the headless host: the same core, a reference software rasterizer, and an intent-based tape format ("walk d 4", not a frame count) contracts/spec/mon-spec.ts the surface, codegen'd to Rust with a byte-compare drift guard apps/mon SPARKWOOD: content, procedural art, the cooker, the guest SDK Verified: 245 core tests, 36 contract/content tests, a deterministic playthrough asserted against frame hashes (out of the house, across the village, take a starter, north to Route One, win two wild battles), and the same journey on the console under PPSSPPHeadless with byte-exact PNG goldens — 300/300 frames presented, boot through warp. Not done, and stated as such in docs/MON.md §2: no QuickJS realm is mounted in the EBOOT yet, so the surface has no JS caller on the console; audio is specified but unimplemented; real hardware is untested. --- apps/mon/README.md | 54 + apps/mon/art/actors.ts | 141 ++ apps/mon/art/creatures.ts | 201 +++ apps/mon/art/font.ts | 158 +++ apps/mon/art/palette.ts | 197 +++ apps/mon/art/raster.ts | 132 ++ apps/mon/art/tiles.ts | 232 ++++ apps/mon/content/game.ts | 774 +++++++++++ apps/mon/content/text.ts | 47 + apps/mon/cook.ts | 609 +++++++++ apps/mon/sdk.ts | 325 +++++ apps/mon/tapes/story.tape | 73 + contracts/spec/gen-mon-rust.ts | 426 ++++++ contracts/spec/mon-spec.ts | 772 +++++++++++ docs/MON.md | 226 +++ docs/RUNTIMES.md | 9 + docs/STRUCTURE.md | 10 +- engine/Cargo.toml | 7 + .../crates/pocketmon-core/Cargo.lock | 10 + .../crates/pocketmon-core/Cargo.toml | 30 + .../crates/pocketmon-core/src/battle/ai.rs | 340 +++++ .../pocketmon-core/src/battle/catching.rs | 202 +++ .../pocketmon-core/src/battle/damage.rs | 729 ++++++++++ .../pocketmon-core/src/battle/effects.rs | 613 +++++++++ .../crates/pocketmon-core/src/battle/mod.rs | 993 ++++++++++++++ .../crates/pocketmon-core/src/battle/tests.rs | 643 +++++++++ .../crates/pocketmon-core/src/content.rs | 1207 +++++++++++++++++ .../crates/pocketmon-core/src/draw.rs | 286 ++++ .../crates/pocketmon-core/src/event.rs | 168 +++ .../crates/pocketmon-core/src/lib.rs | 679 ++++++++++ .../crates/pocketmon-core/src/mon/growth.rs | 148 ++ .../crates/pocketmon-core/src/mon/mod.rs | 650 +++++++++ .../crates/pocketmon-core/src/mon/stats.rs | 345 +++++ .../crates/pocketmon-core/src/rng.rs | 178 +++ .../crates/pocketmon-core/src/save.rs | 639 +++++++++ .../crates/pocketmon-core/src/scene.rs | 547 ++++++++ .../crates/pocketmon-core/src/script.rs | 995 ++++++++++++++ .../crates/pocketmon-core/src/spec.rs | 518 +++++++ .../crates/pocketmon-core/src/surface.rs | 691 ++++++++++ .../crates/pocketmon-core/src/text.rs | 509 +++++++ .../crates/pocketmon-core/src/world/actor.rs | 396 ++++++ .../crates/pocketmon-core/src/world/map.rs | 419 ++++++ .../crates/pocketmon-core/src/world/mod.rs | 8 + .../pocketmon-core/src/world/overworld.rs | 870 ++++++++++++ .../pocketmon/crates/pocketmon-gu/Cargo.toml | 19 + .../pocketmon/crates/pocketmon-gu/src/lib.rs | 330 +++++ .../pocketmon/crates/pocketmon-psp/Cargo.lock | 123 ++ .../pocketmon/crates/pocketmon-psp/Cargo.toml | 43 + .../pocketmon/crates/pocketmon-psp/Psp.toml | 3 + .../crates/pocketmon-psp/src/arena.rs | 198 +++ .../crates/pocketmon-psp/src/capture.rs | 145 ++ .../crates/pocketmon-psp/src/main.rs | 233 ++++ .../pocketmon/crates/pocketmon-sim/Cargo.lock | 14 + .../pocketmon/crates/pocketmon-sim/Cargo.toml | 25 + .../crates/pocketmon-sim/src/main.rs | 614 +++++++++ .../pocketmon/crates/pocketmon-sim/src/png.rs | 111 ++ .../crates/pocketmon-sim/src/raster.rs | 211 +++ package.json | 7 +- tests/e2e/mon-ppsspp.ts | 163 +++ tests/goldens/mon/psp/boot.png | Bin 0 -> 2784 bytes tests/goldens/mon/psp/doormat.png | Bin 0 -> 3531 bytes tests/goldens/mon/psp/village.png | Bin 0 -> 3538 bytes tests/goldens/mon/psp/walking.png | Bin 0 -> 2835 bytes tests/goldens/mon/story.hashes | 15 + tests/mon-content.test.ts | 460 +++++++ tests/mon-contract.test.ts | 80 ++ tools/mon.ts | 189 +++ 67 files changed, 20185 insertions(+), 4 deletions(-) create mode 100644 apps/mon/README.md create mode 100644 apps/mon/art/actors.ts create mode 100644 apps/mon/art/creatures.ts create mode 100644 apps/mon/art/font.ts create mode 100644 apps/mon/art/palette.ts create mode 100644 apps/mon/art/raster.ts create mode 100644 apps/mon/art/tiles.ts create mode 100644 apps/mon/content/game.ts create mode 100644 apps/mon/content/text.ts create mode 100644 apps/mon/cook.ts create mode 100644 apps/mon/sdk.ts create mode 100644 apps/mon/tapes/story.tape create mode 100644 contracts/spec/gen-mon-rust.ts create mode 100644 contracts/spec/mon-spec.ts create mode 100644 docs/MON.md create mode 100644 engine/pocketmon/crates/pocketmon-core/Cargo.lock create mode 100644 engine/pocketmon/crates/pocketmon-core/Cargo.toml create mode 100644 engine/pocketmon/crates/pocketmon-core/src/battle/ai.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/battle/catching.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/battle/damage.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/battle/effects.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/battle/mod.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/battle/tests.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/content.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/draw.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/event.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/lib.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/mon/growth.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/mon/mod.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/mon/stats.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/rng.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/save.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/scene.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/script.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/spec.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/surface.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/text.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/world/actor.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/world/map.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/world/mod.rs create mode 100644 engine/pocketmon/crates/pocketmon-core/src/world/overworld.rs create mode 100644 engine/pocketmon/crates/pocketmon-gu/Cargo.toml create mode 100644 engine/pocketmon/crates/pocketmon-gu/src/lib.rs create mode 100644 engine/pocketmon/crates/pocketmon-psp/Cargo.lock create mode 100644 engine/pocketmon/crates/pocketmon-psp/Cargo.toml create mode 100644 engine/pocketmon/crates/pocketmon-psp/Psp.toml create mode 100644 engine/pocketmon/crates/pocketmon-psp/src/arena.rs create mode 100644 engine/pocketmon/crates/pocketmon-psp/src/capture.rs create mode 100644 engine/pocketmon/crates/pocketmon-psp/src/main.rs create mode 100644 engine/pocketmon/crates/pocketmon-sim/Cargo.lock create mode 100644 engine/pocketmon/crates/pocketmon-sim/Cargo.toml create mode 100644 engine/pocketmon/crates/pocketmon-sim/src/main.rs create mode 100644 engine/pocketmon/crates/pocketmon-sim/src/png.rs create mode 100644 engine/pocketmon/crates/pocketmon-sim/src/raster.rs create mode 100644 tests/e2e/mon-ppsspp.ts create mode 100644 tests/goldens/mon/psp/boot.png create mode 100644 tests/goldens/mon/psp/doormat.png create mode 100644 tests/goldens/mon/psp/village.png create mode 100644 tests/goldens/mon/psp/walking.png create mode 100644 tests/goldens/mon/story.hashes create mode 100644 tests/mon-content.test.ts create mode 100644 tests/mon-contract.test.ts create mode 100644 tools/mon.ts diff --git a/apps/mon/README.md b/apps/mon/README.md new file mode 100644 index 00000000..4c96761c --- /dev/null +++ b/apps/mon/README.md @@ -0,0 +1,54 @@ +# SPARKWOOD + +An original creature-collecting adventure, and the content half of the Pocket +Mon runtime ([docs/MON.md](../../docs/MON.md)). + +Everything here is source. There is no ROM, no importer, and no asset pipeline +that reads one — the creatures, the world, the words and the art are all +written or generated in this directory and cooked into a single `.monpak`. + +``` +content/game.ts species, moves, types, items, maps, trainers, scripts +content/text.ts the string table (a key IS an index; no runtime hashing) +art/font.ts a 5x7 pixel face, hand-authored +art/tiles.ts 8x8 terrain tiles, the 4x4-tile blocks, tile behaviours +art/actors.ts procedural 16x16 walk sheets, twelve poses each +art/creatures.ts procedural 64x64 portraits, front and back +art/palette.ts the one 256-entry CLUT the whole game shares +cook.ts all of the above -> dist/sparkwood.monpak +sdk.ts the guest-side algebra over the `mon` surface +tapes/story.tape the acceptance run +``` + +## Working on it + +```sh +bun tools/mon.ts cook # rebuild the pak +bun tools/mon.ts check # play the acceptance tape, assert the frame hashes +bun tools/mon.ts shots # write a PNG per checkpoint, and the atlas pages +bun tools/mon.ts psp # build the PSP EBOOT with the pak baked in +bun tools/mon.ts run # …and launch it in PPSSPP +``` + +`shots` is the one to reach for first: content bugs are visual, and a +screenshot per checkpoint plus the atlas pages tells you in one look whether +the art is wrong or the drawing is. + +## Adding to the world + +The content tests (`tests/mon-content.test.ts`) enforce the things that are +easy to get wrong and hard to notice: + +- a warp must sit on a cell whose block actually has a door under it — the + bottom-left-tile rule means a door drawn in the wrong quarter of a block is + scenery; +- map connections must be declared from both sides with mirrored offsets, or + walking across a seam and back lands you somewhere else; +- every species needs a move learnable by level 5, or a starter stands there + doing nothing; +- the font must cover every character the content writes, because a missing + glyph is invisible rather than loud. + +Cooking is deterministic: the same checkout produces byte-identical output, and +a test asserts it. If a change makes the pak non-reproducible, that is a bug in +the cooker, not a quirk to work around. diff --git a/apps/mon/art/actors.ts b/apps/mon/art/actors.ts new file mode 100644 index 00000000..4bd50d86 --- /dev/null +++ b/apps/mon/art/actors.ts @@ -0,0 +1,141 @@ +// Overworld actor sprites: 16x16, twelve poses each (4 facings x 3 walk +// frames), laid out one actor per atlas row exactly as `scene.rs` expects. +// +// Drawn procedurally from a small per-cast description rather than pixelled by +// hand: five characters x twelve poses is sixty sprites, and a parameterised +// figure keeps them all consistent — same silhouette, same walk cadence, so +// the cast reads as one world. + +import { PAL } from "./palette.ts"; +import { Surface } from "./raster.ts"; + +export const SPRITE_PX = 16; +export const POSES = 12; + +/** Which way an actor faces; matches `spec::dir`. */ +export const DIR = { down: 0, up: 1, left: 2, right: 3 } as const; + +/** One member of the cast. */ +export interface ActorStyle { + name: string; + hair: number; + /** Torso colour. */ + shirt: number; + /** Leg colour. */ + pants: number; + /** Optional headwear drawn over the hair. */ + cap?: number; + /** Optional long garment replacing the legs (a coat or a dress). */ + robe?: number; +} + +/** The cast, in atlas-row order. Sprite ids in content reference these. */ +export const CAST: ActorStyle[] = [ + { name: "player", hair: PAL.hairDark, shirt: PAL.shirtA, pants: PAL.pants, cap: PAL.capA }, + { name: "mom", hair: PAL.hairLight, shirt: PAL.dress, pants: PAL.dress, robe: PAL.dress }, + { name: "professor", hair: PAL.shade, shirt: PAL.coat, pants: PAL.pants, robe: PAL.coat }, + { name: "rival", hair: PAL.capB, shirt: PAL.shirtB, pants: PAL.pants }, + { name: "hiker", hair: PAL.hairDark, shirt: PAL.hilite, pants: PAL.wood, cap: PAL.capB }, + { name: "villager", hair: PAL.hairLight, shirt: PAL.roofMid, pants: PAL.pants }, +]; + +/** + * Draw one pose. + * + * The figure is eight pixels wide inside a sixteen-pixel cell, which leaves + * room for the outline pass and keeps the feet on the walk grid: the sprite's + * bottom row is the cell's bottom row, and the core draws it at the actor's + * cell origin. + */ +export function drawPose(style: ActorStyle, dir: number, frame: number): Surface { + const s = new Surface(SPRITE_PX, SPRITE_PX); + const skin = PAL.skin; + + // --- head ------------------------------------------------------------- + // Hair cap, then the face inset under it. Facing up shows only hair. + s.rect(4, 1, 8, 4, style.hair); + if (dir !== DIR.up) { + s.rect(5, 3, 6, 4, skin); + // A fringe over the brow, asymmetric when facing sideways. + if (dir === DIR.left) s.rect(5, 3, 3, 1, style.hair); + if (dir === DIR.right) s.rect(8, 3, 3, 1, style.hair); + } else { + s.rect(5, 3, 6, 3, style.hair); + } + // Ears/side hair. + s.rect(4, 4, 1, 2, style.hair); + s.rect(11, 4, 1, 2, style.hair); + + if (style.cap) { + s.rect(4, 1, 8, 2, style.cap); + // A brim, on whichever side the actor is looking. + if (dir === DIR.down) s.rect(4, 3, 8, 1, style.cap); + if (dir === DIR.left) s.rect(3, 3, 4, 1, style.cap); + if (dir === DIR.right) s.rect(9, 3, 4, 1, style.cap); + } + + // --- eyes ------------------------------------------------------------- + if (dir === DIR.down) { + s.set(6, 5, PAL.outline); + s.set(9, 5, PAL.outline); + } else if (dir === DIR.left) { + s.set(6, 5, PAL.outline); + } else if (dir === DIR.right) { + s.set(9, 5, PAL.outline); + } + + // --- torso ------------------------------------------------------------ + const torso = style.robe ?? style.shirt; + s.rect(5, 7, 6, 5, torso); + // Arms, one either side; the trailing arm swings back a pixel while walking. + const swing = frame === 1 ? 1 : frame === 2 ? -1 : 0; + s.rect(4, 8 + Math.max(0, swing), 1, 3, torso); + s.rect(11, 8 + Math.max(0, -swing), 1, 3, torso); + // Hands. + s.set(4, 11 + Math.max(0, swing), skin); + s.set(11, 11 + Math.max(0, -swing), skin); + + // --- legs ------------------------------------------------------------- + if (style.robe) { + // A long garment: flare it out instead of splitting into legs. + s.rect(4, 12, 8, 3, style.robe); + s.rect(5, 15, 2, 1, PAL.shoe); + s.rect(9, 15, 2, 1, PAL.shoe); + } else { + // frame 0 = standing, 1 = left leg forward, 2 = right leg forward. + const leftLen = frame === 2 ? 3 : 4; + const rightLen = frame === 1 ? 3 : 4; + s.rect(5, 12, 2, leftLen, style.pants); + s.rect(9, 12, 2, rightLen, style.pants); + s.rect(5, 11 + leftLen, 2, 1, PAL.shoe); + s.rect(9, 11 + rightLen, 2, 1, PAL.shoe); + } + + s.outline(PAL.outline); + return s; +} + +/** + * The full walk sheet for one actor: twelve 16x16 poses in a row, ordered + * `dir * 3 + frame` to match `scene::actor_uv`. + */ +export function drawSheet(style: ActorStyle): Surface { + const sheet = new Surface(SPRITE_PX * POSES, SPRITE_PX); + for (let dir = 0; dir < 4; dir++) { + for (let frame = 0; frame < 3; frame++) { + const pose = drawPose(style, dir, frame); + pose.blitInto(sheet, (dir * 3 + frame) * SPRITE_PX, 0); + } + } + return sheet; +} + +/** Every sheet, stacked one per row — the ACTOR_PAGE atlas. */ +export function drawCast(): Surface { + const page = new Surface(256, 256); + CAST.forEach((style, i) => { + if ((i + 1) * SPRITE_PX > 256) return; + drawSheet(style).blitInto(page, 0, i * SPRITE_PX); + }); + return page; +} diff --git a/apps/mon/art/creatures.ts b/apps/mon/art/creatures.ts new file mode 100644 index 00000000..cee90125 --- /dev/null +++ b/apps/mon/art/creatures.ts @@ -0,0 +1,201 @@ +// Creature portraits: 64x64 front and back views, generated from a body plan +// plus the species' elemental colour ramp. +// +// Fourteen species x two views is twenty-eight portraits. Pixelling those by +// hand would be the single biggest job in the project and would still drift in +// style; a parameterised plan keeps proportions consistent across a family and +// makes an evolution visibly the *same* creature, larger. + +import { creature, PAL, RAMP } from "./palette.ts"; +import { Surface } from "./raster.ts"; + +export const PORTRAIT_PX = 64; + +/** The silhouettes the bestiary is built from. */ +export type Plan = "pup" | "fish" | "sprout" | "mote" | "rock" | "bird" | "moth"; + +/** How to draw one species. */ +export interface CreatureArt { + plan: Plan; + /** Elemental type index; picks the colour ramp. */ + type: number; + /** 0..1 — how much of the frame the creature fills. */ + size: number; +} + +/** Draw the eyes and mouth of a front view. */ +function face(s: Surface, cx: number, cy: number, spread: number, scale: number): void { + const r = Math.max(2, Math.round(3 * scale)); + for (const dx of [-spread, spread]) { + s.ellipse(cx + dx, cy, r, r, PAL.eyeWhite); + s.ellipse(cx + dx + Math.sign(dx) * 0.5, cy + 0.5, r * 0.55, r * 0.6, PAL.eyePupil); + // A single specular dot does more for the face than any other pixel. + s.set(Math.round(cx + dx - r * 0.35), Math.round(cy - r * 0.4), PAL.eyeWhite); + } + s.rect(Math.round(cx - 2), Math.round(cy + r + 2), 4, 1, PAL.mouth); + s.set(Math.round(cx - 3), Math.round(cy + r + 1), PAL.mouth); + s.set(Math.round(cx + 2), Math.round(cy + r + 1), PAL.mouth); +} + +/** Render one creature. `back` draws the rear view: same body, no face. */ +export function drawCreature(art: CreatureArt, back: boolean): Surface { + const s = new Surface(PORTRAIT_PX, PORTRAIT_PX); + const dark = creature(art.type, RAMP.dark); + const mid = creature(art.type, RAMP.mid); + const light = creature(art.type, RAMP.light); + const accent = creature(art.type, RAMP.accent); + const k = 0.55 + art.size * 0.45; // overall scale + const cx = PORTRAIT_PX / 2; + const floor = PORTRAIT_PX - 4; + + switch (art.plan) { + case "pup": { + const bw = 18 * k; + const bh = 12 * k; + const by = floor - bh - 6 * k; + // legs + for (const dx of [-bw * 0.6, -bw * 0.2, bw * 0.2, bw * 0.6]) { + s.rect(Math.round(cx + dx - 2), Math.round(by + bh - 2), 4, Math.round(8 * k), dark); + } + // tail + s.triangle(cx + bw * 0.8, by, cx + bw * 1.5, by - 8 * k, cx + bw * 0.9, by + 5 * k, mid); + // body + head + s.ellipse(cx, by, bw, bh, mid); + s.ellipse(cx, by + bh * 0.45, bw * 0.75, bh * 0.5, light); + const hx = cx - bw * 0.55; + const hy = by - bh * 0.9; + s.ellipse(hx, hy, 11 * k, 10 * k, mid); + // ears + s.triangle(hx - 8 * k, hy - 6 * k, hx - 3 * k, hy - 15 * k, hx + 1 * k, hy - 5 * k, dark); + s.triangle(hx + 3 * k, hy - 6 * k, hx + 8 * k, hy - 15 * k, hx + 10 * k, hy - 4 * k, dark); + if (back) { + s.ellipse(hx, hy - 2 * k, 7 * k, 5 * k, accent); + } else { + s.ellipse(hx, hy + 5 * k, 5 * k, 3.5 * k, accent); // muzzle + face(s, hx, hy - 1 * k, 5 * k, k); + } + break; + } + + case "fish": { + const bw = 20 * k; + const bh = 13 * k; + const cy = PORTRAIT_PX / 2 + 2; + // tail fin + s.triangle(cx + bw * 0.7, cy, cx + bw * 1.6, cy - 11 * k, cx + bw * 1.6, cy + 11 * k, dark); + // dorsal + ventral + s.triangle(cx - 2, cy - bh, cx + 4 * k, cy - bh - 12 * k, cx + 10 * k, cy - bh * 0.7, accent); + s.triangle(cx - 2, cy + bh, cx + 3 * k, cy + bh + 8 * k, cx + 9 * k, cy + bh * 0.7, dark); + s.ellipse(cx, cy, bw, bh, mid); + s.ellipse(cx - bw * 0.15, cy + bh * 0.4, bw * 0.7, bh * 0.45, light); + // pectoral fin + s.triangle(cx - 2 * k, cy + 2, cx + 8 * k, cy + 9 * k, cx + 9 * k, cy - 1 * k, accent); + if (back) { + s.ellipse(cx - bw * 0.3, cy - bh * 0.2, bw * 0.35, bh * 0.5, accent); + } else { + face(s, cx - bw * 0.45, cy - 2 * k, 5 * k, k); + } + break; + } + + case "sprout": { + const bw = 13 * k; + const bh = 15 * k; + const by = floor - bh - 4 * k; + s.rect(Math.round(cx - 7 * k), Math.round(by + bh - 2), Math.round(5 * k), Math.round(7 * k), dark); + s.rect(Math.round(cx + 2 * k), Math.round(by + bh - 2), Math.round(5 * k), Math.round(7 * k), dark); + s.ellipse(cx, by, bw, bh, mid); + s.ellipse(cx, by + bh * 0.35, bw * 0.7, bh * 0.55, light); + // a pair of leaves and a stem + s.rect(Math.round(cx - 1), Math.round(by - bh - 8 * k), 2, Math.round(9 * k), dark); + s.ellipse(cx - 9 * k, by - bh - 7 * k, 9 * k, 4.5 * k, accent); + s.ellipse(cx + 9 * k, by - bh - 9 * k, 8 * k, 4 * k, accent); + if (!back) face(s, cx, by - 3 * k, 6 * k, k); + break; + } + + case "mote": { + const r = 15 * k; + const cy = PORTRAIT_PX / 2; + // orbiting arcs, drawn behind + s.ellipse(cx - r * 1.5, cy, 4 * k, 9 * k, dark); + s.ellipse(cx + r * 1.5, cy, 4 * k, 9 * k, dark); + s.ellipse(cx, cy, r, r, mid); + s.ellipse(cx, cy + r * 0.3, r * 0.7, r * 0.6, light); + // a spark crown + for (const dx of [-1, 0, 1]) { + s.triangle( + cx + dx * 9 * k - 3, + cy - r + 2, + cx + dx * 9 * k, + cy - r - 10 * k, + cx + dx * 9 * k + 3, + cy - r + 2, + accent, + ); + } + if (!back) face(s, cx, cy - 1, 6 * k, k); + break; + } + + case "rock": { + const w = 17 * k; + const h = 15 * k; + const cy = floor - h - 2; + // an angular body rather than an ellipse: reads as mineral + s.triangle(cx - w, cy + h, cx - w * 0.6, cy - h, cx + w * 0.2, cy + h, mid); + s.triangle(cx - w * 0.2, cy + h, cx + w * 0.7, cy - h * 0.8, cx + w, cy + h, mid); + s.rect(Math.round(cx - w), Math.round(cy + h - 3), Math.round(w * 2), 4, dark); + s.triangle(cx - w * 0.5, cy + h * 0.4, cx - w * 0.1, cy - h * 0.3, cx + w * 0.4, cy + h * 0.4, light); + // stubby arms + s.rect(Math.round(cx - w - 5 * k), Math.round(cy + h * 0.2), Math.round(6 * k), Math.round(5 * k), dark); + s.rect(Math.round(cx + w - 1), Math.round(cy + h * 0.2), Math.round(6 * k), Math.round(5 * k), dark); + if (!back) face(s, cx, cy + h * 0.1, 6 * k, k); + else s.ellipse(cx, cy, w * 0.4, h * 0.4, accent); + break; + } + + case "bird": { + const bw = 12 * k; + const bh = 15 * k; + const cy = PORTRAIT_PX / 2 + 2; + // wings behind the body + s.triangle(cx - bw, cy - 4, cx - bw - 18 * k, cy - 12 * k, cx - bw - 4, cy + 10 * k, dark); + s.triangle(cx + bw, cy - 4, cx + bw + 18 * k, cy - 12 * k, cx + bw + 4, cy + 10 * k, dark); + s.ellipse(cx, cy, bw, bh, mid); + s.ellipse(cx, cy + bh * 0.3, bw * 0.65, bh * 0.5, light); + s.ellipse(cx, cy - bh * 0.85, 9 * k, 8 * k, mid); + // feet + s.rect(Math.round(cx - 6 * k), Math.round(cy + bh - 1), Math.round(4 * k), Math.round(5 * k), accent); + s.rect(Math.round(cx + 2 * k), Math.round(cy + bh - 1), Math.round(4 * k), Math.round(5 * k), accent); + if (back) { + s.ellipse(cx, cy - bh * 0.2, bw * 0.4, bh * 0.4, accent); + } else { + s.triangle(cx - 2, cy - bh * 0.85, cx + 9 * k, cy - bh * 0.7, cx - 2, cy - bh * 0.55, accent); + face(s, cx, cy - bh * 0.95, 4.5 * k, k); + } + break; + } + + case "moth": { + const bw = 6 * k; + const bh = 16 * k; + const cy = PORTRAIT_PX / 2 + 2; + for (const sign of [-1, 1]) { + s.ellipse(cx + sign * 15 * k, cy - 6 * k, 14 * k, 10 * k, mid); + s.ellipse(cx + sign * 13 * k, cy + 8 * k, 10 * k, 8 * k, dark); + s.ellipse(cx + sign * 15 * k, cy - 6 * k, 5 * k, 4 * k, accent); + } + s.ellipse(cx, cy, bw, bh, dark); + s.ellipse(cx, cy - bh * 0.6, 6 * k, 6 * k, mid); + // antennae + s.triangle(cx - 2, cy - bh * 0.9, cx - 10 * k, cy - bh - 6 * k, cx - 1, cy - bh * 0.7, dark); + s.triangle(cx + 2, cy - bh * 0.9, cx + 10 * k, cy - bh - 6 * k, cx + 1, cy - bh * 0.7, dark); + if (!back) face(s, cx, cy - bh * 0.6, 3.5 * k, k * 0.8); + break; + } + } + + s.outline(PAL.outline); + return s; +} diff --git a/apps/mon/art/font.ts b/apps/mon/art/font.ts new file mode 100644 index 00000000..a8ea02e1 --- /dev/null +++ b/apps/mon/art/font.ts @@ -0,0 +1,158 @@ +// The SPARKWOOD font: an original 5x7 pixel face in an 8x8 cell. +// +// Hand-authored rather than rasterized from a TTF. At this size a hinted +// outline font turns to mush, and the whole point of the runtime's look is +// that every pixel is deliberate. Rows are written as '#'/'.' so a glyph can +// be read and edited in place. +// +// Metrics: 5 px wide, 7 px tall, 6 px advance (5 + 1 of side bearing). +// Space and the few wide glyphs override the advance individually. + +export const GLYPH_W = 5; +export const GLYPH_H = 7; +export const CELL = 8; +export const DEFAULT_ADVANCE = 6; + +/** A glyph: seven 5-character rows, top to bottom. */ +export type Glyph = readonly [string, string, string, string, string, string, string]; + +const G = (...rows: string[]) => rows as unknown as Glyph; + +export const FONT: Record = { + " ": G(".....", ".....", ".....", ".....", ".....", ".....", "....."), + A: G(".###.", "#...#", "#...#", "#####", "#...#", "#...#", "#...#"), + B: G("####.", "#...#", "####.", "#...#", "#...#", "#...#", "####."), + C: G(".####", "#....", "#....", "#....", "#....", "#....", ".####"), + D: G("####.", "#...#", "#...#", "#...#", "#...#", "#...#", "####."), + E: G("#####", "#....", "####.", "#....", "#....", "#....", "#####"), + F: G("#####", "#....", "####.", "#....", "#....", "#....", "#...."), + G: G(".####", "#....", "#....", "#..##", "#...#", "#...#", ".####"), + H: G("#...#", "#...#", "#####", "#...#", "#...#", "#...#", "#...#"), + I: G("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "#####"), + J: G("....#", "....#", "....#", "....#", "#...#", "#...#", ".###."), + K: G("#...#", "#..#.", "#.#..", "##...", "#.#..", "#..#.", "#...#"), + L: G("#....", "#....", "#....", "#....", "#....", "#....", "#####"), + M: G("#...#", "##.##", "#.#.#", "#...#", "#...#", "#...#", "#...#"), + N: G("#...#", "##..#", "#.#.#", "#..##", "#...#", "#...#", "#...#"), + O: G(".###.", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."), + P: G("####.", "#...#", "#...#", "####.", "#....", "#....", "#...."), + Q: G(".###.", "#...#", "#...#", "#...#", "#.#.#", "#..#.", ".##.#"), + R: G("####.", "#...#", "#...#", "####.", "#.#..", "#..#.", "#...#"), + S: G(".####", "#....", "#....", ".###.", "....#", "....#", "####."), + T: G("#####", "..#..", "..#..", "..#..", "..#..", "..#..", "..#.."), + U: G("#...#", "#...#", "#...#", "#...#", "#...#", "#...#", ".###."), + V: G("#...#", "#...#", "#...#", "#...#", "#...#", ".#.#.", "..#.."), + W: G("#...#", "#...#", "#...#", "#...#", "#.#.#", "##.##", "#...#"), + X: G("#...#", "#...#", ".#.#.", "..#..", ".#.#.", "#...#", "#...#"), + Y: G("#...#", "#...#", ".#.#.", "..#..", "..#..", "..#..", "..#.."), + Z: G("#####", "....#", "...#.", "..#..", ".#...", "#....", "#####"), + + a: G(".....", ".....", ".###.", "....#", ".####", "#...#", ".####"), + b: G("#....", "#....", "####.", "#...#", "#...#", "#...#", "####."), + c: G(".....", ".....", ".####", "#....", "#....", "#....", ".####"), + d: G("....#", "....#", ".####", "#...#", "#...#", "#...#", ".####"), + e: G(".....", ".....", ".###.", "#...#", "#####", "#....", ".####"), + f: G("..##.", ".#...", "####.", ".#...", ".#...", ".#...", ".#..."), + g: G(".....", ".####", "#...#", "#...#", ".####", "....#", ".###."), + h: G("#....", "#....", "####.", "#...#", "#...#", "#...#", "#...#"), + i: G("..#..", ".....", ".##..", "..#..", "..#..", "..#..", ".###."), + j: G("...#.", ".....", "..##.", "...#.", "...#.", "#..#.", ".##.."), + k: G("#....", "#....", "#..#.", "#.#..", "##...", "#.#..", "#..#."), + l: G(".##..", "..#..", "..#..", "..#..", "..#..", "..#..", ".###."), + m: G(".....", ".....", "##.#.", "#.#.#", "#.#.#", "#...#", "#...#"), + n: G(".....", ".....", "####.", "#...#", "#...#", "#...#", "#...#"), + o: G(".....", ".....", ".###.", "#...#", "#...#", "#...#", ".###."), + p: G(".....", "####.", "#...#", "#...#", "####.", "#....", "#...."), + q: G(".....", ".####", "#...#", "#...#", ".####", "....#", "....#"), + r: G(".....", ".....", "#.##.", "##...", "#....", "#....", "#...."), + s: G(".....", ".....", ".####", "#....", ".###.", "....#", "####."), + t: G(".#...", ".#...", "####.", ".#...", ".#...", ".#..#", "..##."), + u: G(".....", ".....", "#...#", "#...#", "#...#", "#...#", ".####"), + v: G(".....", ".....", "#...#", "#...#", "#...#", ".#.#.", "..#.."), + w: G(".....", ".....", "#...#", "#...#", "#.#.#", "#.#.#", ".#.#."), + x: G(".....", ".....", "#...#", ".#.#.", "..#..", ".#.#.", "#...#"), + y: G(".....", "#...#", "#...#", "#...#", ".####", "....#", ".###."), + z: G(".....", ".....", "#####", "...#.", "..#..", ".#...", "#####"), + + "0": G(".###.", "#...#", "#..##", "#.#.#", "##..#", "#...#", ".###."), + "1": G("..#..", ".##..", "..#..", "..#..", "..#..", "..#..", ".###."), + "2": G(".###.", "#...#", "....#", "...#.", "..#..", ".#...", "#####"), + "3": G("####.", "....#", "....#", ".###.", "....#", "....#", "####."), + "4": G("...#.", "..##.", ".#.#.", "#..#.", "#####", "...#.", "...#."), + "5": G("#####", "#....", "####.", "....#", "....#", "#...#", ".###."), + "6": G(".###.", "#....", "#....", "####.", "#...#", "#...#", ".###."), + "7": G("#####", "....#", "...#.", "..#..", ".#...", ".#...", ".#..."), + "8": G(".###.", "#...#", "#...#", ".###.", "#...#", "#...#", ".###."), + "9": G(".###.", "#...#", "#...#", ".####", "....#", "....#", ".###."), + + ".": G(".....", ".....", ".....", ".....", ".....", ".##..", ".##.."), + ",": G(".....", ".....", ".....", ".....", ".##..", ".##..", ".#..."), + "!": G("..#..", "..#..", "..#..", "..#..", "..#..", ".....", "..#.."), + "?": G(".###.", "#...#", "....#", "...#.", "..#..", ".....", "..#.."), + "'": G("..#..", "..#..", ".....", ".....", ".....", ".....", "....."), + '"': G(".#.#.", ".#.#.", ".....", ".....", ".....", ".....", "....."), + "-": G(".....", ".....", ".....", "#####", ".....", ".....", "....."), + "+": G(".....", "..#..", "..#..", "#####", "..#..", "..#..", "....."), + "/": G("....#", "....#", "...#.", "..#..", ".#...", "#....", "#...."), + ":": G(".....", ".##..", ".##..", ".....", ".##..", ".##..", "....."), + ";": G(".....", ".##..", ".##..", ".....", ".##..", ".##..", ".#..."), + "(": G("...#.", "..#..", ".#...", ".#...", ".#...", "..#..", "...#."), + ")": G(".#...", "..#..", "...#.", "...#.", "...#.", "..#..", ".#..."), + "$": G("..#..", ".####", "#.#..", ".###.", "..#.#", "####.", "..#.."), + "%": G("##..#", "##.#.", "..#..", ".#...", "#..##", "..###", "....."), + "*": G(".....", "#.#.#", ".###.", "#####", ".###.", "#.#.#", "....."), + "=": G(".....", ".....", "#####", ".....", "#####", ".....", "....."), + "<": G("...#.", "..#..", ".#...", "#....", ".#...", "..#..", "...#."), + ">": G(".#...", "..#..", "...#.", "....#", "...#.", "..#..", ".#..."), + "[": G(".###.", ".#...", ".#...", ".#...", ".#...", ".#...", ".###."), + "]": G(".###.", "...#.", "...#.", "...#.", "...#.", "...#.", ".###."), + "_": G(".....", ".....", ".....", ".....", ".....", ".....", "#####"), + + // The three UI marks the core draws by name. + "▶": G(".#...", ".##..", ".###.", ".####", ".###.", ".##..", ".#..."), // ▶ cursor + "▼": G(".....", "#####", "#####", ".###.", ".###.", "..#..", "....."), // ▼ more + "♥": G(".....", ".#.#.", "#####", "#####", ".###.", "..#..", "....."), // ♥ hp +}; + +/** Glyphs whose advance is not [`DEFAULT_ADVANCE`]. */ +export const ADVANCE_OVERRIDE: Record = { + " ": 4, + ".": 3, + ",": 3, + "!": 3, + "'": 2, + ":": 3, + ";": 3, + i: 4, + l: 4, + "1": 5, +}; + +/** The advance of one character. */ +export function advanceOf(ch: string): number { + return ADVANCE_OVERRIDE[ch] ?? DEFAULT_ADVANCE; +} + +/** Every character the font carries, in a stable order. */ +export function characters(): string[] { + return Object.keys(FONT); +} + +/** + * Rasterize one glyph into an 8x8 cell of palette indices. + * + * The glyph sits at (0, 0) of the cell with its 7 rows top-aligned, so the + * baseline is consistent and the core's line spacing is just the cell height. + */ +export function rasterize(ch: string, ink: number): Uint8Array { + const cell = new Uint8Array(CELL * CELL); // 0 = transparent + const glyph = FONT[ch]; + if (!glyph) return cell; + for (let y = 0; y < GLYPH_H; y++) { + const row = glyph[y] ?? ""; + for (let x = 0; x < GLYPH_W; x++) { + if (row[x] === "#") cell[y * CELL + x] = ink; + } + } + return cell; +} diff --git a/apps/mon/art/palette.ts b/apps/mon/art/palette.ts new file mode 100644 index 00000000..96566b62 --- /dev/null +++ b/apps/mon/art/palette.ts @@ -0,0 +1,197 @@ +// The SPARKWOOD palette: one 256-entry CLUT the whole game shares. +// +// A single global palette is what lets the PSP backend bind one CLUT8 texture +// per atlas page and never touch palette state mid-frame — and it is what +// makes screen-wide colour effects (a fade, a battle flash) a CLUT rewrite +// instead of a re-upload. +// +// Index 0 is always fully transparent. Everything else is grouped so a range +// can be swapped wholesale later without renumbering art. + +/** One palette entry. */ +export interface Rgba { + r: number; + g: number; + b: number; + a: number; +} + +const rgb = (r: number, g: number, b: number): Rgba => ({ r, g, b, a: 255 }); + +/** Named indices. Art files reference these, never raw numbers. */ +export const PAL = { + transparent: 0, + + // --- interface --------------------------------------------------------- + ink: 1, + paper: 2, + shade: 3, + white: 4, + black: 5, + hilite: 6, + + // --- terrain ----------------------------------------------------------- + grassDark: 10, + grassMid: 11, + grassLight: 12, + pathDark: 13, + pathMid: 14, + pathLight: 15, + treeDark: 16, + treeMid: 17, + treeLight: 18, + waterDark: 19, + waterMid: 20, + waterLight: 21, + wallDark: 22, + wallMid: 23, + wallLight: 24, + floorDark: 25, + floorMid: 26, + floorLight: 27, + roofDark: 28, + roofMid: 29, + roofLight: 30, + sand: 31, + ledge: 32, + wood: 33, + doorDark: 34, + flower: 35, + glass: 36, + + // --- actors ------------------------------------------------------------ + skin: 40, + skinShade: 41, + hairDark: 42, + hairLight: 43, + shirtA: 44, + shirtB: 45, + pants: 46, + shoe: 47, + outline: 48, + coat: 49, + dress: 50, + capA: 51, + capB: 52, + + // --- creatures --------------------------------------------------------- + /// Each type owns a four-step ramp at `creatureBase + type * 4`. + creatureBase: 64, + eyeWhite: 100, + eyePupil: 101, + mouth: 102, + bellyLight: 103, +} as const; + +/** Steps within a creature type ramp. */ +export const RAMP = { dark: 0, mid: 1, light: 2, accent: 3 } as const; + +/** The palette index of a creature colour. */ +export function creature(type: number, step: number): number { + return PAL.creatureBase + type * 4 + step; +} + +/** + * Per-type four-step ramps, in `contracts` type order: + * NORMAL, EMBER, TIDE, LEAF, SPARK, STONE, GALE, SHADE. + */ +const TYPE_RAMPS: Rgba[][] = [ + // NORMAL — warm neutral + [rgb(0x8a, 0x7d, 0x6a), rgb(0xb8, 0xa8, 0x90), rgb(0xdd, 0xd0, 0xba), rgb(0xf0, 0xe6, 0xd2)], + // EMBER — coal to flame + [rgb(0x7a, 0x24, 0x18), rgb(0xc4, 0x4a, 0x22), rgb(0xf0, 0x8c, 0x38), rgb(0xff, 0xd0, 0x70)], + // TIDE — deep to foam + [rgb(0x15, 0x3d, 0x6b), rgb(0x2a, 0x74, 0xb0), rgb(0x5c, 0xb0, 0xdc), rgb(0xd0, 0xf0, 0xf8)], + // LEAF — bark to shoot + [rgb(0x1e, 0x4d, 0x24), rgb(0x38, 0x86, 0x3a), rgb(0x74, 0xc0, 0x5c), rgb(0xcc, 0xec, 0x90)], + // SPARK — dusk to arc + [rgb(0x5c, 0x48, 0x10), rgb(0xc0, 0xa0, 0x1c), rgb(0xf4, 0xdc, 0x40), rgb(0xff, 0xf8, 0xb0)], + // STONE — shadow to chalk + [rgb(0x40, 0x3a, 0x34), rgb(0x77, 0x6d, 0x60), rgb(0xa8, 0x9e, 0x8c), rgb(0xd8, 0xd0, 0xc0)], + // GALE — storm to cloud + [rgb(0x3d, 0x50, 0x6b), rgb(0x74, 0x90, 0xb4), rgb(0xa8, 0xc4, 0xdc), rgb(0xe8, 0xf2, 0xf8)], + // SHADE — void to wisp + [rgb(0x24, 0x18, 0x38), rgb(0x4c, 0x34, 0x70), rgb(0x80, 0x60, 0xa8), rgb(0xc0, 0xa4, 0xd8)], +]; + +/** Build the full 256-entry CLUT. */ +export function buildPalette(): Rgba[] { + const p: Rgba[] = Array.from({ length: 256 }, () => ({ r: 0, g: 0, b: 0, a: 0 })); + const set = (i: number, c: Rgba) => { + p[i] = c; + }; + + set(PAL.ink, rgb(0x18, 0x1c, 0x24)); + set(PAL.paper, rgb(0xf4, 0xf2, 0xe4)); + set(PAL.shade, rgb(0x8a, 0x92, 0x86)); + set(PAL.white, rgb(0xff, 0xff, 0xff)); + set(PAL.black, rgb(0x00, 0x00, 0x00)); + set(PAL.hilite, rgb(0x4c, 0x8c, 0x5c)); + + set(PAL.grassDark, rgb(0x3c, 0x6e, 0x3a)); + set(PAL.grassMid, rgb(0x5a, 0x96, 0x4c)); + set(PAL.grassLight, rgb(0x84, 0xbc, 0x66)); + set(PAL.pathDark, rgb(0x9a, 0x84, 0x5e)); + set(PAL.pathMid, rgb(0xc4, 0xac, 0x7e)); + set(PAL.pathLight, rgb(0xe0, 0xcc, 0xa2)); + set(PAL.treeDark, rgb(0x1e, 0x44, 0x24)); + set(PAL.treeMid, rgb(0x2e, 0x64, 0x32)); + set(PAL.treeLight, rgb(0x4a, 0x8c, 0x44)); + set(PAL.waterDark, rgb(0x1c, 0x44, 0x84)); + set(PAL.waterMid, rgb(0x2e, 0x6c, 0xb4)); + set(PAL.waterLight, rgb(0x6c, 0xa8, 0xdc)); + set(PAL.wallDark, rgb(0x6e, 0x5c, 0x4c)); + set(PAL.wallMid, rgb(0x9c, 0x86, 0x6e)); + set(PAL.wallLight, rgb(0xc8, 0xb2, 0x96)); + set(PAL.floorDark, rgb(0x8c, 0x72, 0x58)); + set(PAL.floorMid, rgb(0xb4, 0x96, 0x74)); + set(PAL.floorLight, rgb(0xd8, 0xbe, 0x9c)); + set(PAL.roofDark, rgb(0x7a, 0x2c, 0x30)); + set(PAL.roofMid, rgb(0xac, 0x44, 0x44)); + set(PAL.roofLight, rgb(0xd0, 0x6c, 0x60)); + set(PAL.sand, rgb(0xdc, 0xcc, 0x94)); + set(PAL.ledge, rgb(0x84, 0x6c, 0x4c)); + set(PAL.wood, rgb(0x8a, 0x5c, 0x34)); + set(PAL.doorDark, rgb(0x40, 0x2c, 0x1c)); + set(PAL.flower, rgb(0xe8, 0x88, 0xb0)); + set(PAL.glass, rgb(0x9c, 0xc8, 0xe4)); + + set(PAL.skin, rgb(0xf0, 0xc4, 0x9c)); + set(PAL.skinShade, rgb(0xc8, 0x98, 0x70)); + set(PAL.hairDark, rgb(0x3c, 0x28, 0x1c)); + set(PAL.hairLight, rgb(0x6c, 0x48, 0x2c)); + set(PAL.shirtA, rgb(0x2c, 0x5c, 0xa8)); + set(PAL.shirtB, rgb(0x44, 0x84, 0xd4)); + set(PAL.pants, rgb(0x34, 0x38, 0x50)); + set(PAL.shoe, rgb(0x28, 0x24, 0x24)); + set(PAL.outline, rgb(0x20, 0x1c, 0x28)); + set(PAL.coat, rgb(0xec, 0xec, 0xf0)); + set(PAL.dress, rgb(0xc4, 0x54, 0x8c)); + set(PAL.capA, rgb(0xc4, 0x3c, 0x34)); + set(PAL.capB, rgb(0xe8, 0x6c, 0x54)); + + for (let t = 0; t < TYPE_RAMPS.length; t++) { + const ramp = TYPE_RAMPS[t]!; + for (let s = 0; s < 4; s++) set(creature(t, s), ramp[s]!); + } + + set(PAL.eyeWhite, rgb(0xf8, 0xf8, 0xf8)); + set(PAL.eyePupil, rgb(0x1c, 0x18, 0x24)); + set(PAL.mouth, rgb(0x8c, 0x30, 0x38)); + set(PAL.bellyLight, rgb(0xf4, 0xe8, 0xd0)); + + return p; +} + +/** Pack the palette into the u32 ABGR bytes MONPAK's APAL section wants. */ +export function packPalette(): Uint8Array { + const p = buildPalette(); + const out = new Uint8Array(256 * 4); + const dv = new DataView(out.buffer); + for (let i = 0; i < 256; i++) { + const c = p[i]!; + // 0xAABBGGRR — the repo-wide ABGR convention. + dv.setUint32(i * 4, ((c.a << 24) | (c.b << 16) | (c.g << 8) | c.r) >>> 0, true); + } + return out; +} diff --git a/apps/mon/art/raster.ts b/apps/mon/art/raster.ts new file mode 100644 index 00000000..1b9e20cc --- /dev/null +++ b/apps/mon/art/raster.ts @@ -0,0 +1,132 @@ +// A tiny indexed-colour raster surface — the shared drawing primitives the +// procedural art in `actors.ts` and `creatures.ts` is built from. +// +// Index 0 is transparent, so "unset" and "see-through" are the same thing and +// the outline pass below can find a silhouette's edge by looking for zeros. + +export class Surface { + readonly w: number; + readonly h: number; + readonly px: Uint8Array; + + constructor(w: number, h: number) { + this.w = w; + this.h = h; + this.px = new Uint8Array(w * h); + } + + inside(x: number, y: number): boolean { + return x >= 0 && y >= 0 && x < this.w && y < this.h; + } + + get(x: number, y: number): number { + return this.inside(x, y) ? this.px[y * this.w + x]! : 0; + } + + set(x: number, y: number, c: number): void { + if (this.inside(x, y)) this.px[y * this.w + x] = c; + } + + /** Set only where nothing has been drawn yet (paint behind). */ + setIfEmpty(x: number, y: number, c: number): void { + if (this.inside(x, y) && this.px[y * this.w + x] === 0) this.px[y * this.w + x] = c; + } + + rect(x: number, y: number, w: number, h: number, c: number): void { + for (let j = y; j < y + h; j++) for (let i = x; i < x + w; i++) this.set(i, j, c); + } + + /** Axis-aligned filled ellipse, centre (cx, cy), radii (rx, ry). */ + ellipse(cx: number, cy: number, rx: number, ry: number, c: number): void { + if (rx <= 0 || ry <= 0) return; + for (let y = Math.floor(cy - ry); y <= Math.ceil(cy + ry); y++) { + for (let x = Math.floor(cx - rx); x <= Math.ceil(cx + rx); x++) { + const dx = (x - cx) / rx; + const dy = (y - cy) / ry; + if (dx * dx + dy * dy <= 1) this.set(x, y, c); + } + } + } + + /** Filled triangle through three points. */ + triangle( + x0: number, + y0: number, + x1: number, + y1: number, + x2: number, + y2: number, + c: number, + ): void { + const minX = Math.floor(Math.min(x0, x1, x2)); + const maxX = Math.ceil(Math.max(x0, x1, x2)); + const minY = Math.floor(Math.min(y0, y1, y2)); + const maxY = Math.ceil(Math.max(y0, y1, y2)); + const area = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0); + if (area === 0) return; + for (let y = minY; y <= maxY; y++) { + for (let x = minX; x <= maxX; x++) { + const w0 = ((x1 - x0) * (y - y0) - (x - x0) * (y1 - y0)) / area; + const w1 = ((x - x0) * (y2 - y0) - (x2 - x0) * (y - y0)) / area; + const w2 = 1 - w0 - w1; + if (w0 >= -0.001 && w1 >= -0.001 && w2 >= -0.001) this.set(x, y, c); + } + } + } + + /** Mirror the left half onto the right, for symmetric subjects. */ + mirrorX(): void { + for (let y = 0; y < this.h; y++) { + for (let x = 0; x < this.w / 2; x++) { + this.px[y * this.w + (this.w - 1 - x)] = this.px[y * this.w + x]!; + } + } + } + + /** + * Draw a one-pixel outline around every filled region. + * + * A transparent pixel that touches a filled one becomes `c`. Run this last: + * at 16x16 on a busy tile background, an unoutlined sprite disappears. + */ + outline(c: number): void { + const src = Uint8Array.from(this.px); + const at = (x: number, y: number) => + x >= 0 && y >= 0 && x < this.w && y < this.h ? src[y * this.w + x]! : 0; + for (let y = 0; y < this.h; y++) { + for (let x = 0; x < this.w; x++) { + if (src[y * this.w + x] !== 0) continue; + if (at(x - 1, y) || at(x + 1, y) || at(x, y - 1) || at(x, y + 1)) { + this.px[y * this.w + x] = c; + } + } + } + } + + /** Blit into a destination surface at (dx, dy), skipping transparent px. */ + blitInto(dst: Surface, dx: number, dy: number): void { + for (let y = 0; y < this.h; y++) { + for (let x = 0; x < this.w; x++) { + const c = this.px[y * this.w + x]!; + if (c !== 0) dst.set(dx + x, dy + y, c); + } + } + } +} + +/** + * A small deterministic PRNG so procedural art is byte-identical on every + * machine and every run — the cooked pak has to hash the same in CI as it + * does locally. + */ +export function seeded(seed: number): () => number { + let s = seed >>> 0 || 0x9e3779b9; + return () => { + s ^= s << 13; + s >>>= 0; + s ^= s >> 17; + s ^= s << 5; + s >>>= 0; + return s / 0x1_0000_0000; + }; +} diff --git a/apps/mon/art/tiles.ts b/apps/mon/art/tiles.ts new file mode 100644 index 00000000..73baa035 --- /dev/null +++ b/apps/mon/art/tiles.ts @@ -0,0 +1,232 @@ +// Terrain: the 8x8 tiles, the 4x4-tile blocks maps are laid out in, and the +// tile -> behaviour table the bottom-left-tile collision rule reads. +// +// Tiles are written as eight 8-character rows so the art is editable in place. +// The character map below is the only place palette indices appear. + +import { PAL } from "./palette.ts"; + +/** Painting characters -> palette indices. */ +const CH: Record = { + " ": PAL.transparent, + g: PAL.grassMid, + G: PAL.grassDark, + h: PAL.grassLight, + p: PAL.pathMid, + P: PAL.pathDark, + q: PAL.pathLight, + t: PAL.treeMid, + T: PAL.treeDark, + u: PAL.treeLight, + w: PAL.waterMid, + W: PAL.waterDark, + v: PAL.waterLight, + b: PAL.wallMid, + B: PAL.wallDark, + c: PAL.wallLight, + f: PAL.floorMid, + F: PAL.floorDark, + e: PAL.floorLight, + r: PAL.roofMid, + R: PAL.roofDark, + s: PAL.roofLight, + d: PAL.doorDark, + o: PAL.wood, + l: PAL.ledge, + y: PAL.sand, + k: PAL.flower, + x: PAL.glass, + "#": PAL.ink, + ".": PAL.paper, + "-": PAL.shade, + "*": PAL.hilite, +}; + +/** Tile ids. The behaviour table below is keyed by these. */ +export const TILE = { + void: 0, + grass: 1, + tallGrass: 2, + path: 3, + tree: 4, + water: 5, + wall: 6, + floor: 7, + door: 8, + sign: 9, + ledge: 10, + counter: 11, + roof: 12, + window: 13, + flower: 14, + stairs: 15, + sand: 16, + fence: 17, + table: 18, + rug: 19, +} as const; + +/** Tile art, indexed by tile id. */ +export const TILE_ART: Record = { + [TILE.void]: ["########", "########", "########", "########", "########", "########", "########", "########"], + [TILE.grass]: ["gggggggg", "gghggggg", "gggggggg", "ggggGggg", "gggggggg", "ghgggggg", "gggggggg", "ggggggGg"], + [TILE.tallGrass]: ["gggggggg", "gGgggGgg", "hGghhGgh", "GGgGGGgG", "gGGggGGg", "hGghhGgh", "GGgGGGgG", "gGgggGgg"], + [TILE.path]: ["pppppppp", "ppppqppp", "pppppppp", "ppPppppp", "pppppqpp", "pppppppp", "ppppppPp", "pqpppppp"], + [TILE.tree]: ["TTuuuuTT", "TuuttuuT", "uuttttuu", "uttttttu", "uttttttu", "TuttttuT", "TTuTTuTT", "TTToTTTT"], + [TILE.water]: ["wwwwwwww", "wwvwwwww", "Wwwwwwvw", "wwwwWwww", "wvwwwwww", "wwwwwwWw", "wwWwvwww", "wwwwwwww"], + [TILE.wall]: ["BBBBBBBB", "BccccccB", "BcbbbbcB", "BcbbbbcB", "BcbbbbcB", "BcbbbbcB", "BccccccB", "BBBBBBBB"], + [TILE.floor]: ["ffffffff", "ffffffff", "ffefffff", "ffffffff", "ffffffff", "fffffeff", "ffffffff", "FfffffFf"], + [TILE.door]: ["BBBBBBBB", "BooooooB", "BoddddoB", "BoddddoB", "Bodd*doB", "BoddddoB", "BoddddoB", "BBBBBBBB"], + [TILE.sign]: ["gggggggg", "oooooooo", "o......o", "o.####.o", "o.####.o", "o......o", "oooooooo", "ggoggogg"], + [TILE.ledge]: ["gggggggg", "gggggggg", "llllllll", "llllllll", "PPPPPPPP", "PPPPPPPP", "pppppppp", "pppppppp"], + [TILE.counter]: ["oooooooo", "oqqqqqqo", "oqqqqqqo", "oooooooo", "dddddddd", "dddddddd", "dddddddd", "dddddddd"], + [TILE.roof]: ["rrrrrrrr", "rsrrrsrr", "RRRRRRRR", "rrrrrrrr", "rsrrrsrr", "RRRRRRRR", "rrrrrrrr", "RRRRRRRR"], + [TILE.window]: ["cccccccc", "cBBBBBBc", "cBxxxxBc", "cBxxxxBc", "cBxxxxBc", "cBxxxxBc", "cBBBBBBc", "BBBBBBBB"], + [TILE.flower]: ["gggggggg", "ggkggggg", "gkkkgggg", "ggkggkgg", "ggggkkkg", "gggggkgg", "gggggggg", "gggggggg"], + [TILE.stairs]: ["BBBBBBBB", "cccccccc", "BBBBBBBB", "cccccccc", "BBBBBBBB", "cccccccc", "BBBBBBBB", "cccccccc"], + [TILE.sand]: ["yyyyyyyy", "yyyPyyyy", "yyyyyyyy", "yPyyyyyy", "yyyyyyPy", "yyyyyyyy", "yyPyyyyy", "yyyyyyyy"], + [TILE.fence]: ["gggggggg", "oooooooo", "gogggogg", "gogggogg", "oooooooo", "gogggogg", "gogggogg", "gggggggg"], + [TILE.table]: ["oooooooo", "oqqqqqqo", "oqqqqqqo", "oqqqqqqo", "oooooooo", "fdffffdf", "fdffffdf", "fdffffdf"], + [TILE.rug]: ["ffffffff", "frrrrrrf", "frssssrf", "frsRRsrf", "frsRRsrf", "frssssrf", "frrrrrrf", "ffffffff"], +}; + +/** + * Cell behaviour per tile id, using `spec::cell::*` values. The core reads + * this as a flat 256-byte table, so anything unlisted is a wall — the + * fail-closed default that keeps a content bug from dropping the player + * through the world. + */ +export const TILE_BEHAVIOR: Record = { + [TILE.void]: 0, // wall + [TILE.grass]: 1, // floor + [TILE.tallGrass]: 2, // grass + [TILE.path]: 1, + [TILE.tree]: 0, + [TILE.water]: 3, // water + [TILE.wall]: 0, + [TILE.floor]: 1, + [TILE.door]: 4, // door + [TILE.sign]: 0, + [TILE.ledge]: 6, // ledgeDown + [TILE.counter]: 7, // counter + [TILE.roof]: 0, + [TILE.window]: 0, + [TILE.flower]: 1, + [TILE.stairs]: 5, // warp + [TILE.sand]: 1, + [TILE.fence]: 0, + [TILE.table]: 0, + [TILE.rug]: 1, +}; + +/** Block ids — the unit maps are laid out in. */ +export const BLOCK = { + grass: 0, + tall: 1, + path: 2, + tree: 3, + water: 4, + house: 5, + wall: 6, + floor: 7, + sign: 8, + ledge: 9, + flower: 10, + fence: 11, + counter: 12, + stairs: 13, + sand: 14, + rug: 15, + table: 16, + lab: 17, + void: 18, + doorway: 19, +} as const; + +/** A block filled with one tile. */ +const solid = (t: number): number[] => Array.from({ length: 16 }, () => t); + +/** + * A composite block, written as four rows of four tile letters. + * + * Remember the collision rule while editing: cell (0,0) reads tile (0,1), + * cell (1,0) reads (2,1), cell (0,1) reads (0,3) and cell (1,1) reads (2,3). + * Those four positions are the ones that decide whether a cell is walkable. + */ +function grid(rows: [string, string, string, string], map: Record): number[] { + const out: number[] = []; + for (const row of rows) { + for (let x = 0; x < 4; x++) { + const ch = row[x] ?? " "; + out.push(map[ch] ?? TILE.void); + } + } + return out; +} + +const M = { + ".": TILE.grass, + "#": TILE.wall, + R: TILE.roof, + D: TILE.door, + W: TILE.window, + S: TILE.sign, + f: TILE.floor, + o: TILE.counter, + T: TILE.table, +}; + +/** Block definitions, indexed by block id. */ +export const BLOCK_TILES: Record = { + [BLOCK.grass]: solid(TILE.grass), + [BLOCK.tall]: solid(TILE.tallGrass), + [BLOCK.path]: solid(TILE.path), + [BLOCK.tree]: solid(TILE.tree), + [BLOCK.water]: solid(TILE.water), + // Roof over a wall, with the door in the bottom-right cell. + [BLOCK.house]: grid(["RRRR", "RRRR", "#W##", "##D#"], M), + [BLOCK.wall]: solid(TILE.wall), + [BLOCK.floor]: solid(TILE.floor), + // A sign in the bottom-left cell, grass elsewhere. + [BLOCK.sign]: grid(["....", "....", "SS..", "SS.."], M), + [BLOCK.ledge]: solid(TILE.ledge), + [BLOCK.flower]: solid(TILE.flower), + [BLOCK.fence]: solid(TILE.fence), + [BLOCK.counter]: solid(TILE.counter), + [BLOCK.stairs]: solid(TILE.stairs), + [BLOCK.sand]: solid(TILE.sand), + [BLOCK.rug]: solid(TILE.rug), + [BLOCK.table]: solid(TILE.table), + // The lab: same silhouette, door on the left so it reads differently. + [BLOCK.lab]: grid(["RRRR", "RRRR", "W##W", "##D#"], M), + [BLOCK.void]: solid(TILE.void), + // An indoor doorway: floor above, door below, so leaving a building is a + // step down onto the mat. + [BLOCK.doorway]: grid(["ffff", "ffff", "f##f", "fDDf"], M), +}; + +/** Highest tile id in use, for sizing the atlas row. */ +export const TILE_COUNT = Object.keys(TILE_ART).length; + +/** Rasterize one tile into 64 palette indices. */ +export function rasterizeTile(id: number): Uint8Array { + const out = new Uint8Array(64); + const art = TILE_ART[id]; + if (!art) return out; + for (let y = 0; y < 8; y++) { + const row = art[y] ?? ""; + for (let x = 0; x < 8; x++) { + out[y * 8 + x] = CH[row[x] ?? " "] ?? PAL.transparent; + } + } + return out; +} + +/** The 256-byte behaviour table the cooker writes into the TLES section. */ +export function behaviorTable(): Uint8Array { + const out = new Uint8Array(256); // 0 = wall everywhere by default + for (const [id, behavior] of Object.entries(TILE_BEHAVIOR)) { + out[Number(id)] = behavior; + } + return out; +} diff --git a/apps/mon/content/game.ts b/apps/mon/content/game.ts new file mode 100644 index 00000000..d3273f72 --- /dev/null +++ b/apps/mon/content/game.ts @@ -0,0 +1,774 @@ +// SPARKWOOD — the game. +// +// Every creature, move, type, item, map, trainer and line of dialogue lives +// here, in TypeScript, and is cooked into one MONPAK. Nothing is derived from +// any existing game's data files: this is the clean-room half of docs/MON.md +// §1, and the whole reason the runtime ships playable from a fresh checkout. +// +// The *mechanics* the numbers feed into are the Gen-1 formulas ported in +// `pocketmon-core`; the numbers themselves, the creatures, the world and the +// words are ours. + +import { CTRL, TextTable } from "./text.ts"; +import { BLOCK } from "../art/tiles.ts"; +import type { Plan } from "../art/creatures.ts"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** Type indices — the order also picks each type's colour ramp. */ +export const TYPE = { + normal: 0, + ember: 1, + tide: 2, + leaf: 3, + spark: 4, + stone: 5, + gale: 6, + shade: 7, +} as const; + +export const TYPE_NAMES = ["NORMAL", "EMBER", "TIDE", "LEAF", "SPARK", "STONE", "GALE", "SHADE"]; + +/** Damage category per type — the Gen-1 physical/special-by-type split. */ +const PHYSICAL = 0; +const SPECIAL = 1; +const STATUS = 2; +export const TYPE_CATEGORY = [ + PHYSICAL, // normal + SPECIAL, // ember + SPECIAL, // tide + SPECIAL, // leaf + SPECIAL, // spark + PHYSICAL, // stone + PHYSICAL, // gale + PHYSICAL, // shade +]; + +/** + * The effectiveness table, in x10 fixed point. Only non-neutral rows appear; + * the core applies each row separately with its own floor. + * + * The shape is a deliberate ring — ember beats leaf beats tide beats ember — + * with stone and gale as a second ring and shade sitting outside both, so a + * player can reason about a matchup they have never seen. + */ +export const MATCHUPS: Array<[number, number, number]> = [ + [TYPE.ember, TYPE.leaf, 20], + [TYPE.ember, TYPE.tide, 5], + [TYPE.ember, TYPE.stone, 5], + [TYPE.ember, TYPE.ember, 5], + [TYPE.tide, TYPE.ember, 20], + [TYPE.tide, TYPE.stone, 20], + [TYPE.tide, TYPE.tide, 5], + [TYPE.tide, TYPE.leaf, 5], + [TYPE.leaf, TYPE.tide, 20], + [TYPE.leaf, TYPE.stone, 20], + [TYPE.leaf, TYPE.leaf, 5], + [TYPE.leaf, TYPE.ember, 5], + [TYPE.leaf, TYPE.gale, 5], + [TYPE.spark, TYPE.tide, 20], + [TYPE.spark, TYPE.gale, 20], + [TYPE.spark, TYPE.leaf, 5], + [TYPE.spark, TYPE.stone, 0], + [TYPE.stone, TYPE.ember, 20], + [TYPE.stone, TYPE.gale, 20], + [TYPE.stone, TYPE.leaf, 5], + [TYPE.stone, TYPE.tide, 5], + [TYPE.gale, TYPE.leaf, 20], + [TYPE.gale, TYPE.spark, 5], + [TYPE.gale, TYPE.stone, 5], + [TYPE.shade, TYPE.shade, 20], + [TYPE.shade, TYPE.normal, 0], + [TYPE.normal, TYPE.shade, 0], + [TYPE.normal, TYPE.stone, 5], +]; + +// --------------------------------------------------------------------------- +// Moves +// --------------------------------------------------------------------------- + +/** Effect ids, mirroring `spec::effect`. */ +export const FX = { + none: 0, + burnChance: 1, + paralyzeChance: 3, + sleep: 5, + confuse: 6, + flinchChance: 7, + atkDown: 8, + defDown: 9, + spdDown: 10, + defUp: 14, + drain: 17, + recoil: 18, + twoHit: 20, + highCrit: 22, + hyperBeam: 24, + heal: 28, + focusEnergy: 38, + leechSeed: 45, +} as const; + +/** Move flag bits, mirroring `spec::MOVE_FLAG_*`. */ +export const MF = { highCrit: 1, multiHit: 2, charge: 4, recharge: 8, priority: 16 } as const; + +export interface MoveDef { + id: number; + name: string; + type: number; + power: number; + accuracy: number; + pp: number; + category?: number; + effect?: number; + chance?: number; + flags?: number; + desc: string; +} + +export const MOVES: MoveDef[] = [ + { id: 1, name: "TACKLE", type: TYPE.normal, power: 40, accuracy: 100, pp: 35, desc: "A plain body check." }, + { id: 2, name: "SCRATCH", type: TYPE.normal, power: 40, accuracy: 100, pp: 35, desc: "Rakes with claws." }, + { id: 3, name: "GROWL", type: TYPE.normal, power: 0, accuracy: 100, pp: 40, category: STATUS, effect: FX.atkDown, desc: "Lowers ATTACK." }, + { id: 4, name: "HARDEN", type: TYPE.normal, power: 0, accuracy: 100, pp: 30, category: STATUS, effect: FX.defUp, desc: "Raises DEFENSE." }, + { id: 5, name: "QUICK JAB", type: TYPE.normal, power: 40, accuracy: 100, pp: 30, flags: MF.priority, desc: "Always strikes first." }, + { id: 6, name: "BITE", type: TYPE.shade, power: 60, accuracy: 100, pp: 25, effect: FX.flinchChance, chance: 30, desc: "May make the foe flinch." }, + { id: 7, name: "EMBER", type: TYPE.ember, power: 40, accuracy: 100, pp: 25, effect: FX.burnChance, chance: 10, desc: "May burn the foe." }, + { id: 8, name: "FLAME", type: TYPE.ember, power: 90, accuracy: 85, pp: 15, effect: FX.burnChance, chance: 10, desc: "A searing blast." }, + { id: 9, name: "WATERJET", type: TYPE.tide, power: 40, accuracy: 100, pp: 25, desc: "A jet of cold water." }, + { id: 10, name: "TORRENT", type: TYPE.tide, power: 90, accuracy: 85, pp: 15, desc: "A crashing wave." }, + { id: 11, name: "VINE WHIP", type: TYPE.leaf, power: 45, accuracy: 100, pp: 25, desc: "Lashes with a vine." }, + { id: 12, name: "LEAF BLADE", type: TYPE.leaf, power: 90, accuracy: 90, pp: 15, flags: MF.highCrit, desc: "Critical hits often." }, + { id: 13, name: "SPARK", type: TYPE.spark, power: 40, accuracy: 100, pp: 30, effect: FX.paralyzeChance, chance: 10, desc: "May paralyze." }, + { id: 14, name: "THUNDERCLAP", type: TYPE.spark, power: 90, accuracy: 85, pp: 15, effect: FX.paralyzeChance, chance: 10, desc: "A deafening bolt." }, + { id: 15, name: "ROCK TOSS", type: TYPE.stone, power: 50, accuracy: 90, pp: 20, desc: "Hurls a loose rock." }, + { id: 16, name: "BOULDER", type: TYPE.stone, power: 85, accuracy: 80, pp: 10, desc: "Drops a huge stone." }, + { id: 17, name: "GUST", type: TYPE.gale, power: 40, accuracy: 100, pp: 35, desc: "Whips up the air." }, + { id: 18, name: "CYCLONE", type: TYPE.gale, power: 85, accuracy: 85, pp: 15, desc: "A spiralling wind." }, + { id: 19, name: "DUSK TOUCH", type: TYPE.shade, power: 45, accuracy: 100, pp: 25, desc: "A chilling brush." }, + { id: 20, name: "LULLABY", type: TYPE.normal, power: 0, accuracy: 55, pp: 15, category: STATUS, effect: FX.sleep, desc: "Puts the foe to sleep." }, + { id: 21, name: "RECOVER", type: TYPE.normal, power: 0, accuracy: 100, pp: 10, category: STATUS, effect: FX.heal, desc: "Restores half HP." }, + { id: 22, name: "DRAIN SEED", type: TYPE.leaf, power: 0, accuracy: 90, pp: 10, category: STATUS, effect: FX.leechSeed, desc: "Saps HP each turn." }, + { id: 23, name: "FOCUS", type: TYPE.normal, power: 0, accuracy: 100, pp: 30, category: STATUS, effect: FX.focusEnergy, desc: "Gets pumped up." }, + { id: 24, name: "HAZE RAY", type: TYPE.shade, power: 0, accuracy: 100, pp: 10, category: STATUS, effect: FX.confuse, desc: "Confuses the foe." }, + { id: 25, name: "SIPHON", type: TYPE.leaf, power: 40, accuracy: 100, pp: 15, effect: FX.drain, desc: "Drains half the damage." }, + { id: 26, name: "DOUBLE KICK", type: TYPE.normal, power: 30, accuracy: 100, pp: 20, effect: FX.twoHit, desc: "Strikes twice." }, + { id: 27, name: "SAND SPRAY", type: TYPE.stone, power: 0, accuracy: 100, pp: 15, category: STATUS, effect: FX.spdDown, desc: "Lowers SPEED." }, +]; + +// --------------------------------------------------------------------------- +// Species +// --------------------------------------------------------------------------- + +export const GROWTH = { + mediumFast: 0, + slightlyFast: 1, + slightlySlow: 2, + mediumSlow: 3, + fast: 4, + slow: 5, +} as const; + +export interface SpeciesDef { + id: number; + name: string; + type1: number; + type2?: number; + hp: number; + atk: number; + def: number; + spd: number; + spc: number; + catchRate: number; + baseExp: number; + growth: number; + plan: Plan; + size: number; + /** [level, moveId] pairs, in learn order. */ + learnset: Array<[number, number]>; + evolveLevel?: number; + evolveInto?: number; + dex: string; +} + +export const SPECIES: SpeciesDef[] = [ + { + id: 1, name: "EMBERKIT", type1: TYPE.ember, hp: 39, atk: 52, def: 43, spd: 65, spc: 60, + catchRate: 45, baseExp: 62, growth: GROWTH.mediumSlow, plan: "pup", size: 0.3, + learnset: [[1, 2], [1, 3], [7, 7], [13, 5], [20, 6], [28, 8]], + evolveLevel: 16, evolveInto: 2, + dex: "A stray ember that learned\nto follow footprints home.", + }, + { + id: 2, name: "CINDERPUP", type1: TYPE.ember, hp: 58, atk: 64, def: 58, spd: 80, spc: 65, + catchRate: 45, baseExp: 142, growth: GROWTH.mediumSlow, plan: "pup", size: 0.62, + learnset: [[1, 2], [1, 7], [15, 5], [22, 6], [30, 8], [38, 23]], + evolveLevel: 32, evolveInto: 3, + dex: "Its coat smoulders when it\nis pleased. Mind the rugs.", + }, + { + id: 3, name: "BLAZEHOUND", type1: TYPE.ember, hp: 78, atk: 84, def: 78, spd: 100, spc: 85, + catchRate: 45, baseExp: 240, growth: GROWTH.mediumSlow, plan: "pup", size: 0.95, + learnset: [[1, 8], [1, 6], [34, 5], [42, 23], [50, 26]], + dex: "Runs the ridge line at dusk\nand never once looks back.", + }, + { + id: 4, name: "DRIPFIN", type1: TYPE.tide, hp: 44, atk: 48, def: 65, spd: 43, spc: 50, + catchRate: 45, baseExp: 63, growth: GROWTH.mediumSlow, plan: "fish", size: 0.3, + learnset: [[1, 1], [1, 4], [7, 9], [16, 27], [22, 24], [30, 10]], + evolveLevel: 16, evolveInto: 5, + dex: "Keeps a bead of rain in its\nfin for a dry afternoon.", + }, + { + id: 5, name: "TIDEFIN", type1: TYPE.tide, hp: 59, atk: 63, def: 80, spd: 58, spc: 65, + catchRate: 45, baseExp: 142, growth: GROWTH.mediumSlow, plan: "fish", size: 0.62, + learnset: [[1, 1], [1, 9], [18, 27], [25, 24], [33, 10], [40, 21]], + evolveLevel: 32, evolveInto: 6, + dex: "Swims upstream out of habit,\neven across a wet floor.", + }, + { + id: 6, name: "MAELSTROM", type1: TYPE.tide, hp: 79, atk: 83, def: 100, spd: 78, spc: 85, + catchRate: 45, baseExp: 240, growth: GROWTH.mediumSlow, plan: "fish", size: 0.95, + learnset: [[1, 10], [1, 4], [36, 21], [44, 24], [52, 18]], + dex: "The lake turns over once a\nyear. This is why.", + }, + { + id: 7, name: "SEEDLING", type1: TYPE.leaf, hp: 45, atk: 49, def: 49, spd: 45, spc: 65, + catchRate: 45, baseExp: 64, growth: GROWTH.mediumSlow, plan: "sprout", size: 0.3, + learnset: [[1, 1], [1, 3], [7, 11], [13, 22], [20, 25], [27, 12]], + evolveLevel: 16, evolveInto: 8, + dex: "Sleeps in the sun and calls\nit hard work. It is.", + }, + { + id: 8, name: "BRAMBLE", type1: TYPE.leaf, hp: 60, atk: 62, def: 63, spd: 60, spc: 80, + catchRate: 45, baseExp: 142, growth: GROWTH.mediumSlow, plan: "sprout", size: 0.62, + learnset: [[1, 11], [1, 22], [15, 25], [24, 12], [32, 4], [40, 21]], + evolveLevel: 32, evolveInto: 9, + dex: "Grows a thorn for every\nfight it did not start.", + }, + { + id: 9, name: "THORNWOOD", type1: TYPE.leaf, hp: 80, atk: 82, def: 83, spd: 80, spc: 100, + catchRate: 45, baseExp: 240, growth: GROWTH.mediumSlow, plan: "sprout", size: 0.95, + learnset: [[1, 12], [1, 25], [38, 21], [46, 22], [54, 26]], + dex: "Old enough that the path\nbends politely around it.", + }, + { + id: 10, name: "PIPSQUEAK", type1: TYPE.normal, hp: 40, atk: 45, def: 40, spd: 56, spc: 35, + catchRate: 255, baseExp: 55, growth: GROWTH.mediumFast, plan: "pup", size: 0.24, + learnset: [[1, 1], [1, 3], [6, 5], [12, 26], [18, 6]], + dex: "Everywhere. Cheerful about\nit. Impossible to dislike.", + }, + { + id: 11, name: "ZAPMOTE", type1: TYPE.spark, hp: 35, atk: 55, def: 40, spd: 90, spc: 50, + catchRate: 190, baseExp: 82, growth: GROWTH.mediumFast, plan: "mote", size: 0.4, + learnset: [[1, 13], [1, 5], [10, 3], [17, 23], [26, 14]], + dex: "Static that decided it had\nsomewhere to be.", + }, + { + id: 12, name: "PEBBLET", type1: TYPE.stone, hp: 50, atk: 60, def: 80, spd: 30, spc: 30, + catchRate: 190, baseExp: 73, growth: GROWTH.mediumFast, plan: "rock", size: 0.42, + learnset: [[1, 15], [1, 4], [11, 27], [19, 16], [28, 26]], + dex: "Sits very still and hopes\nyou will step around it.", + }, + { + id: 13, name: "GUSTLING", type1: TYPE.gale, hp: 40, atk: 45, def: 40, spd: 75, spc: 40, + catchRate: 220, baseExp: 58, growth: GROWTH.mediumFast, plan: "bird", size: 0.4, + learnset: [[1, 17], [1, 2], [9, 5], [16, 3], [25, 18]], + dex: "Rides the draught over the\nvillage roofs all morning.", + }, + { + id: 14, name: "DUSKMOTH", type1: TYPE.shade, hp: 45, atk: 40, def: 45, spd: 60, spc: 70, + catchRate: 150, baseExp: 88, growth: GROWTH.mediumFast, plan: "moth", size: 0.46, + learnset: [[1, 19], [1, 20], [12, 24], [21, 6], [30, 21]], + dex: "Wings dusted with the last\nlight of the day.", + }, +]; + +// --------------------------------------------------------------------------- +// Items +// --------------------------------------------------------------------------- + +export const ITEM_KIND = { + none: 0, ball: 1, heal: 2, status: 3, revive: 4, boost: 5, key: 6, escape: 7, repel: 8, +} as const; + +export interface ItemDef { + id: number; + name: string; + kind: number; + param: number; + price: number; + desc: string; +} + +export const ITEMS: ItemDef[] = [ + { id: 1, name: "SPARK BALL", kind: ITEM_KIND.ball, param: 0, price: 200, desc: "Catches a wild one." }, + { id: 2, name: "GREAT BALL", kind: ITEM_KIND.ball, param: 1, price: 600, desc: "A better ball." }, + { id: 3, name: "ULTRA BALL", kind: ITEM_KIND.ball, param: 2, price: 1200, desc: "A very good ball." }, + { id: 4, name: "POTION", kind: ITEM_KIND.heal, param: 2, price: 300, desc: "Restores 20 HP." }, + { id: 5, name: "TONIC", kind: ITEM_KIND.heal, param: 5, price: 700, desc: "Restores 50 HP." }, + { id: 6, name: "ANTIDOTE", kind: ITEM_KIND.status, param: 0, price: 100, desc: "Cures any condition." }, + { id: 7, name: "ROPE", kind: ITEM_KIND.escape, param: 0, price: 550, desc: "Flees a wild battle." }, + { id: 8, name: "GRIT", kind: ITEM_KIND.boost, param: 1, price: 500, desc: "Raises ATTACK in battle." }, +]; + +// --------------------------------------------------------------------------- +// The world +// --------------------------------------------------------------------------- + +/** Map ids. */ +export const MAP = { home: 1, village: 2, lab: 3, route: 4, glade: 5 } as const; + +/** Layout characters -> block ids. */ +const B: Record = { + ".": BLOCK.grass, + G: BLOCK.tall, + P: BLOCK.path, + T: BLOCK.tree, + "~": BLOCK.water, + H: BLOCK.house, + L: BLOCK.lab, + S: BLOCK.sign, + F: BLOCK.flower, + "=": BLOCK.fence, + "#": BLOCK.wall, + f: BLOCK.floor, + r: BLOCK.rug, + t: BLOCK.table, + c: BLOCK.counter, + D: BLOCK.doorway, + " ": BLOCK.void, +}; + +export interface WarpDef { x: number; y: number; destMap: number; destWarp: number; dir: number } +export interface SignDef { x: number; y: number; text: string } +export interface ActorPlace { + x: number; + y: number; + dir: number; + behavior: number; + sprite: number; + text?: string; + script?: string; + trainer?: number; + flagGate?: number; +} +export interface MapSpec { + id: number; + name: string; + rows: string[]; + tileset?: number; + border: number; + indoor?: boolean; + music?: number; + encounterRate?: number; + slots?: Array<[number, number]>; // [speciesId, level] + warps?: WarpDef[]; + signs?: SignDef[]; + actors?: ActorPlace[]; + /** [north, south, west, east] map ids, -1 for none. */ + conn?: [number, number, number, number]; + connOff?: [number, number, number, number]; +} + +/** Sprite ids into the actor atlas (see `art/actors.ts` CAST order). */ +export const SPRITE = { player: 0, mom: 1, professor: 2, rival: 3, hiker: 4, villager: 5 } as const; +const DIR = { down: 0, up: 1, left: 2, right: 3 } as const; +const BEHAVIOR = { still: 0, wander: 1, paceH: 2, paceV: 3, spin: 4 } as const; + +/** Event flags the scripts use. */ +export const FLAG = { + gotStarter: 1, + beatRival: 2, + metMom: 3, + hikerBeaten: 10, + villagerBeaten: 11, +} as const; + +export const MAPS: MapSpec[] = [ + { + id: MAP.home, + name: "HOME", + indoor: true, + border: BLOCK.void, + rows: [ + "#####", + "#rtf#", + "#fff#", + "#ffD#", + ], + // The doorway block sits at block (3,3); its door cell is (3*2+1, 3*2+1). + warps: [{ x: 7, y: 7, destMap: MAP.village, destWarp: 0, dir: DIR.down }], + actors: [ + { + x: 2, y: 3, dir: DIR.down, behavior: BEHAVIOR.still, sprite: SPRITE.mom, + script: "mom", + }, + ], + }, + { + id: MAP.village, + name: "SPARKWOOD", + border: BLOCK.tree, + music: 1, + rows: [ + "TTTTPTTTTT", + "T...P....T", + "T.H..L...T", + "T.PP.P...T", + "T.PPPPP..T", + "T...P....T", + "T.S.P..F.T", + "T......F.T", + "T.=====..T", + "TTTTTTTTTT", + ], + // Warp 0 is the arrival pad outside the player's house. + warps: [ + { x: 5, y: 5, destMap: MAP.home, destWarp: 0, dir: DIR.up }, + { x: 11, y: 5, destMap: MAP.lab, destWarp: 0, dir: DIR.up }, + ], + signs: [ + { + x: 4, y: 13, + text: "SPARKWOOD VILLAGE" + CTRL.line + "Where the road starts.", + }, + ], + actors: [ + { + x: 14, y: 15, dir: DIR.down, behavior: BEHAVIOR.wander, sprite: SPRITE.villager, + text: "The tall grass north of\nhere is full of critters." + CTRL.page + + "Do not go in without a\npartner of your own.", + }, + ], + conn: [MAP.route, -1, -1, -1], + connOff: [0, 0, 0, 0], + }, + { + id: MAP.lab, + name: "LAB", + indoor: true, + border: BLOCK.void, + rows: [ + "######", + "#cttc#", + "#ffff#", + "#ffDf#", + ], + warps: [{ x: 7, y: 7, destMap: MAP.village, destWarp: 1, dir: DIR.down }], + actors: [ + { + x: 5, y: 4, dir: DIR.down, behavior: BEHAVIOR.still, sprite: SPRITE.professor, + script: "professor", + }, + { + x: 3, y: 6, dir: DIR.right, behavior: BEHAVIOR.still, sprite: SPRITE.rival, + script: "rival", flagGate: FLAG.beatRival, + }, + ], + }, + { + id: MAP.route, + name: "ROUTE ONE", + border: BLOCK.tree, + music: 2, + encounterRate: 30, + slots: [ + [10, 3], [10, 3], [13, 3], [10, 4], [13, 4], + [11, 4], [7, 4], [1, 4], [4, 4], [14, 5], + ], + rows: [ + "TTTTPTTTTT", + "T..GGG...T", + "T.GGGGG..T", + "T..GGG..=T", + "T...P...=T", + "T.GG.P.GGT", + "T.GG.P.GGT", + "T.S..P...T", + "T..GGP...T", + "T..GGP...T", + "T....P...T", + "TTTTPTTTTT", + ], + signs: [ + { + x: 4, y: 15, + text: "ROUTE ONE" + CTRL.line + "SPARKWOOD to the south." + CTRL.page + + "Tall grass hides tall\ntempers. Step carefully.", + }, + ], + actors: [ + { + x: 7, y: 9, dir: DIR.left, behavior: BEHAVIOR.spin, sprite: SPRITE.hiker, + trainer: 2, flagGate: FLAG.hikerBeaten, + }, + { + x: 13, y: 17, dir: DIR.up, behavior: BEHAVIOR.still, sprite: SPRITE.villager, + trainer: 3, flagGate: FLAG.villagerBeaten, + }, + ], + conn: [MAP.glade, MAP.village, -1, -1], + connOff: [-1, 0, 0, 0], + }, + { + id: MAP.glade, + name: "STILL GLADE", + border: BLOCK.tree, + music: 2, + encounterRate: 22, + slots: [ + [14, 6], [14, 6], [11, 6], [13, 7], [14, 7], + [11, 7], [12, 7], [10, 8], [14, 8], [14, 9], + ], + rows: [ + "TTTPTTTT", + "T..~~..T", + "T.~~~~.T", + "T..~~..T", + "T...P..T", + "TF..P.FT", + "T...P..T", + "TTTPTTTT", + ], + conn: [-1, MAP.route, -1, -1], + connOff: [0, 1, 0, 0], + }, +]; + +// --------------------------------------------------------------------------- +// Trainers +// --------------------------------------------------------------------------- + +export interface TrainerDef { + id: number; + name: string; + aiClass: number; + reward: number; + party: Array<{ species: number; level: number; moves: number[] }>; + /** Shown before the fight; the actor's talk line. */ + intro: string; + /** Shown after losing. */ + defeat: string; + /** Set once beaten. */ + flag: number; +} + +export const TRAINERS: TrainerDef[] = [ + { + id: 1, name: "RIVAL", aiClass: 1, reward: 12, + party: [{ species: 10, level: 5, moves: [1, 3, 0, 0] }], + intro: "Took you long enough!\nLet me see what you got.", + defeat: "Fine. FINE. I will be\nready next time.", + flag: FLAG.beatRival, + }, + { + id: 2, name: "HIKER RAB", aiClass: 2, reward: 20, + party: [ + { species: 12, level: 7, moves: [15, 4, 0, 0] }, + { species: 12, level: 7, moves: [15, 27, 0, 0] }, + ], + intro: "Rocks all the way up.\nRocks all the way down!", + defeat: "Ha! Solid work.", + flag: FLAG.hikerBeaten, + }, + { + id: 3, name: "WALKER INE", aiClass: 3, reward: 24, + party: [{ species: 13, level: 9, moves: [17, 2, 5, 0] }], + intro: "The wind said you were\ncoming up the road.", + defeat: "It did not say you would\nwin, though.", + flag: FLAG.villagerBeaten, + }, +]; + +// --------------------------------------------------------------------------- +// Scripts +// --------------------------------------------------------------------------- +// +// Rows are `[verb, ...args]`, matching `spec::verb`. A string argument is +// interned into the text table by the cooker and replaced with its key. A +// `["label", name]` row is a jump target; jumps name the label. + +export const VERB = { + end: 0, showText: 1, ask: 2, jump: 3, jumpIfTrue: 4, jumpIfFalse: 5, + setFlag: 6, clearFlag: 7, checkFlag: 8, checkItem: 9, giveItem: 10, takeItem: 11, + startBattle: 12, warp: 13, wait: 14, movePlayer: 15, moveNpc: 16, faceNpc: 17, + facePlayer: 18, showObject: 19, hideObject: 20, playSound: 21, playCry: 22, + playMusic: 23, stopMusic: 24, healParty: 25, givemon: 26, giveMoney: 27, + checkBattleResult: 28, trainerBattle: 29, openMart: 30, replaceBlock: 31, + fade: 32, panCamera: 33, emote: 34, label: 35, hook: 36, setField: 37, + choice: 38, waitFlag: 39, textOpts: 40, +} as const; + +/** One script row. Strings are interned; `{ label: "x" }` targets a label. */ +export type Row = [number, ...Array]; + +export interface ScriptDef { + /** The key an actor's `script` field refers to. */ + name: string; + rows: Row[]; +} + +export const SCRIPTS: ScriptDef[] = [ + { + name: "mom", + rows: [ + [VERB.facePlayer, 1], + [VERB.checkFlag, FLAG.gotStarter], + [VERB.jumpIfTrue, { label: "after" }], + [VERB.showText, "Off to see the PROFESSOR?" + CTRL.page + + "Do not come home without\na partner!"], + [VERB.setFlag, FLAG.metMom], + [VERB.end], + [VERB.label, "after"], + [VERB.showText, "Look at you two." + CTRL.page + "Go on. The road is long\nand the light is good."], + [VERB.end], + ], + }, + { + name: "professor", + rows: [ + [VERB.facePlayer, 1], + [VERB.checkFlag, FLAG.gotStarter], + [VERB.jumpIfTrue, { label: "already" }], + [VERB.showText, "Ah, there you are." + CTRL.page + + "Three of them hatched this\nweek. One should go with you."], + + [VERB.label, "offer_ember"], + [VERB.ask, "EMBERKIT, then?"], + [VERB.jumpIfFalse, { label: "offer_tide" }], + [VERB.givemon, 1, 5], + [VERB.playCry, 1], + [VERB.showText, "EMBERKIT is yours."], + [VERB.jump, { label: "given" }], + + [VERB.label, "offer_tide"], + [VERB.ask, "DRIPFIN, perhaps?"], + [VERB.jumpIfFalse, { label: "offer_leaf" }], + [VERB.givemon, 4, 5], + [VERB.playCry, 4], + [VERB.showText, "DRIPFIN is yours."], + [VERB.jump, { label: "given" }], + + [VERB.label, "offer_leaf"], + [VERB.ask, "SEEDLING it is?"], + [VERB.jumpIfFalse, { label: "offer_ember" }], + [VERB.givemon, 7, 5], + [VERB.playCry, 7], + [VERB.showText, "SEEDLING is yours."], + + [VERB.label, "given"], + [VERB.setFlag, FLAG.gotStarter], + [VERB.giveItem, 1, 5], + [VERB.giveItem, 4, 2], + [VERB.showText, "Take these too. Five BALLS\nand a pair of POTIONS."], + [VERB.end], + + [VERB.label, "already"], + [VERB.showText, "Route One runs north out\nof the village." + CTRL.page + + "Everything you need is\nalready walking beside you."], + [VERB.end], + ], + }, + { + name: "rival", + rows: [ + [VERB.facePlayer, 2], + [VERB.checkFlag, FLAG.gotStarter], + [VERB.jumpIfFalse, { label: "wait" }], + [VERB.checkFlag, FLAG.beatRival], + [VERB.jumpIfTrue, { label: "done" }], + [VERB.showText, "Took you long enough!" + CTRL.line + "Let me see what you got."], + [VERB.trainerBattle, 1], + [VERB.checkBattleResult], + [VERB.jumpIfFalse, { label: "lost" }], + [VERB.setFlag, FLAG.beatRival], + [VERB.showText, "Fine. FINE. I will be\nready next time."], + [VERB.end], + [VERB.label, "lost"], + [VERB.showText, "Better luck when you have\nput in the hours."], + [VERB.end], + [VERB.label, "wait"], + [VERB.showText, "Pick one already. I am not\ngetting any younger."], + [VERB.end], + [VERB.label, "done"], + [VERB.showText, "Go north. I will catch up."], + [VERB.end], + ], + }, +]; + +// --------------------------------------------------------------------------- +// Assembly +// --------------------------------------------------------------------------- + +/** Everything the cooker needs, with every string already interned. */ +export interface BuiltContent { + text: TextTable; + typeNames: number[]; + speciesNameKeys: number[]; + speciesDexKeys: number[]; + moveNameKeys: number[]; + moveDescKeys: number[]; + itemNameKeys: number[]; + itemDescKeys: number[]; + mapNameKeys: number[]; + trainerNameKeys: number[]; + /** script name -> the text key it is filed under (an actor's `text_key`). */ + scriptKeys: Map; +} + +/** + * Intern every string the game uses and hand back the keys. + * + * Scripts are filed under a synthetic key derived from their name, and an + * actor that names a script gets that same key as its `text_key` — which is + * how the core's talk dispatch finds "a script keyed by the actor's text id" + * without a second lookup table. + */ +export function buildText(): BuiltContent { + const text = new TextTable(); + const typeNames = TYPE_NAMES.map((n) => text.key(n)); + const speciesNameKeys = SPECIES.map((s) => text.key(s.name)); + const speciesDexKeys = SPECIES.map((s) => text.key(s.dex)); + const moveNameKeys = MOVES.map((m) => text.key(m.name)); + const moveDescKeys = MOVES.map((m) => text.key(m.desc)); + const itemNameKeys = ITEMS.map((i) => text.key(i.name)); + const itemDescKeys = ITEMS.map((i) => text.key(i.desc)); + const mapNameKeys = MAPS.map((m) => text.key(m.name)); + const trainerNameKeys = TRAINERS.map((t) => text.key(t.name)); + const scriptKeys = new Map(); + for (const s of SCRIPTS) scriptKeys.set(s.name, text.key(`$script:${s.name}`)); + return { + text, + typeNames, + speciesNameKeys, + speciesDexKeys, + moveNameKeys, + moveDescKeys, + itemNameKeys, + itemDescKeys, + mapNameKeys, + trainerNameKeys, + scriptKeys, + }; +} + +/** Parse a map's ASCII rows into a flat block array. */ +export function blocksOf(spec: MapSpec): { w: number; h: number; blocks: number[] } { + const h = spec.rows.length; + const w = Math.max(...spec.rows.map((r) => r.length)); + const blocks: number[] = []; + for (let y = 0; y < h; y++) { + const row = spec.rows[y] ?? ""; + for (let x = 0; x < w; x++) { + const ch = row[x] ?? " "; + const id = B[ch]; + if (id === undefined) throw new Error(`map ${spec.name}: unknown block char '${ch}'`); + blocks.push(id); + } + } + return { w, h, blocks }; +} diff --git a/apps/mon/content/text.ts b/apps/mon/content/text.ts new file mode 100644 index 00000000..6ef1733f --- /dev/null +++ b/apps/mon/content/text.ts @@ -0,0 +1,47 @@ +// The string table. +// +// Every piece of text in the game — creature names, move names, dialogue, +// signs — is interned here and referenced everywhere else by its u16 key. +// That is what keeps the `mon` surface's records fixed-size and the runtime +// free of any hashing: a key IS an index into this array. +// +// Key 0 is always the empty string, which the core treats as "no text" (a +// `show_text` on key 0 opens no box, so a script cannot deadlock on it). + +export class TextTable { + private readonly list: string[] = [""]; + private readonly index = new Map([["", 0]]); + + /** Intern a string, returning its key. Repeats share one entry. */ + key(s: string): number { + const hit = this.index.get(s); + if (hit !== undefined) return hit; + const id = this.list.length; + if (id > 0xffff) throw new Error("text table overflow: keys are u16"); + this.list.push(s); + this.index.set(s, id); + return id; + } + + /** Every string, in key order. */ + all(): readonly string[] { + return this.list; + } + + get size(): number { + return this.list.length; + } +} + +/** + * Control characters, spelled out because TS string escapes do not cover them + * and the core's text engine looks for exactly these code points. + */ +export const CTRL = { + /** Hard line break inside a page. */ + line: "\n", + /** `\v` in the upstream engine: scroll one line. */ + scroll: "\u000b", + /** `\f` in the upstream engine: page break, waits for A then clears. */ + page: "\u000c", +} as const; diff --git a/apps/mon/cook.ts b/apps/mon/cook.ts new file mode 100644 index 00000000..2df7b8ad --- /dev/null +++ b/apps/mon/cook.ts @@ -0,0 +1,609 @@ +// The SPARKWOOD content cooker: TypeScript game data + procedural art -> one +// MONPAK blob the Rust core parses in a single linear pass. +// +// Run: bun apps/mon/cook.ts [out.monpak] +// +// This file is the whole "content pipeline" the upstream project spends its +// `src/import/` directory on — except the input is source, not a ROM, so +// there is no verification gate, no private cache, and no first-boot wizard +// (docs/MON.md §1). +// +// Determinism is a hard requirement: the same checkout must produce a +// byte-identical pak on every machine, because the PSP goldens hash it. +// Nothing here reads the clock, the environment or an unseeded RNG. + +import { + MONPAK_ALIGN, + MONPAK_HEADER_SIZE, + MONPAK_MAGIC, + MONPAK_TAG, + MONPAK_VERSION, + SCRIPT_VERSION, + SLOT_COUNT, +} from "../../contracts/spec/mon-spec.ts"; +import { CAST, drawCast, SPRITE_PX } from "./art/actors.ts"; +import { drawCreature, PORTRAIT_PX } from "./art/creatures.ts"; +import { advanceOf, CELL as FONT_CELL, characters, rasterize } from "./art/font.ts"; +import { PAL, packPalette } from "./art/palette.ts"; +import { Surface } from "./art/raster.ts"; +import { behaviorTable, BLOCK_TILES, rasterizeTile, TILE_ART } from "./art/tiles.ts"; +import { + blocksOf, + buildText, + ITEMS, + MAPS, + MATCHUPS, + MOVES, + SCRIPTS, + SPECIES, + TRAINERS, + TYPE_CATEGORY, + TYPE_NAMES, + VERB, + type MapSpec, + type Row, +} from "./content/game.ts"; +import type { TextTable } from "./content/text.ts"; + +const PAGE_PX = 256; + +/** Atlas page assignments. `scene.rs` hard-codes 0, 1 and 2+; the font page + * travels in the FONT header, so it only has to agree with itself. */ +const PAGE = { tiles: 0, actors: 1, portraitFirst: 2, portraitCount: 2, font: 4 } as const; + +// --------------------------------------------------------------------------- +// Byte writer +// --------------------------------------------------------------------------- + +class Writer { + private buf: Uint8Array; + private view: DataView; + private len = 0; + + constructor(capacity = 1 << 16) { + this.buf = new Uint8Array(capacity); + this.view = new DataView(this.buf.buffer); + } + + private need(n: number): void { + if (this.len + n <= this.buf.length) return; + let cap = this.buf.length * 2; + while (cap < this.len + n) cap *= 2; + const next = new Uint8Array(cap); + next.set(this.buf.subarray(0, this.len)); + this.buf = next; + this.view = new DataView(this.buf.buffer); + } + + get length(): number { + return this.len; + } + + u8(v: number): this { + this.need(1); + this.view.setUint8(this.len, v & 0xff); + this.len += 1; + return this; + } + + u16(v: number): this { + this.need(2); + this.view.setUint16(this.len, v & 0xffff, true); + this.len += 2; + return this; + } + + i16(v: number): this { + this.need(2); + this.view.setInt16(this.len, v, true); + this.len += 2; + return this; + } + + u32(v: number): this { + this.need(4); + this.view.setUint32(this.len, v >>> 0, true); + this.len += 4; + return this; + } + + i32(v: number): this { + this.need(4); + this.view.setInt32(this.len, v | 0, true); + this.len += 4; + return this; + } + + bytes(b: Uint8Array): this { + this.need(b.length); + this.buf.set(b, this.len); + this.len += b.length; + return this; + } + + /** Pad to a multiple of `n`. */ + align(n: number): this { + while (this.len % n !== 0) this.u8(0); + return this; + } + + /** Overwrite a u32 already written (offset back-patching). */ + patchU32(at: number, v: number): void { + this.view.setUint32(at, v >>> 0, true); + } + + finish(): Uint8Array { + return this.buf.subarray(0, this.len).slice(); + } +} + +// --------------------------------------------------------------------------- +// Atlas +// --------------------------------------------------------------------------- + +/** Build every atlas page, in page order. */ +function buildPages(): Surface[] { + const pages: Surface[] = []; + + // --- page 0: terrain tiles, 32 per row ------------------------------- + const tiles = new Surface(PAGE_PX, PAGE_PX); + for (const key of Object.keys(TILE_ART)) { + const id = Number(key); + const px = rasterizeTile(id); + const ox = (id % 32) * 8; + const oy = Math.floor(id / 32) * 8; + for (let y = 0; y < 8; y++) { + for (let x = 0; x < 8; x++) tiles.set(ox + x, oy + y, px[y * 8 + x]!); + } + } + pages.push(tiles); + + // --- page 1: actor walk sheets --------------------------------------- + pages.push(drawCast()); + + // --- pages 2..: creature portraits, 4x4 per page ---------------------- + const portraits: Surface[] = []; + for (let i = 0; i < PAGE.portraitCount; i++) portraits.push(new Surface(PAGE_PX, PAGE_PX)); + SPECIES.forEach((s, i) => { + for (const [view, back] of [ + [0, false], + [1, true], + ] as const) { + const index = i * 2 + view; + const page = Math.floor(index / 16); + if (page >= portraits.length) return; + const within = index % 16; + const ox = (within % 4) * PORTRAIT_PX; + const oy = Math.floor(within / 4) * PORTRAIT_PX; + drawCreature({ plan: s.plan, type: s.type1, size: s.size }, back).blitInto( + portraits[page]!, + ox, + oy, + ); + } + }); + pages.push(...portraits); + + // --- page 4: the font ------------------------------------------------- + const font = new Surface(PAGE_PX, PAGE_PX); + const chars = characters(); + chars.forEach((ch, i) => { + const ox = (i % 32) * FONT_CELL; + const oy = Math.floor(i / 32) * FONT_CELL; + const cell = rasterize(ch, PAL.ink); + for (let y = 0; y < FONT_CELL; y++) { + for (let x = 0; x < FONT_CELL; x++) font.set(ox + x, oy + y, cell[y * FONT_CELL + x]!); + } + }); + pages.push(font); + + return pages; +} + +function sectionAtlas(pages: Surface[]): Uint8Array { + const w = new Writer(1 << 19); + w.u16(pages.length).u16(0); + for (const p of pages) { + w.u16(p.w).u16(p.h).u32(p.px.length).bytes(p.px).align(4); + } + return w.finish(); +} + +function sectionFont(): Uint8Array { + const w = new Writer(); + const chars = characters(); + w.u16(chars.length).u8(FONT_CELL).u8(PAGE.font); + chars.forEach((ch, i) => { + const ox = (i % 32) * FONT_CELL; + const oy = Math.floor(i / 32) * FONT_CELL; + w.u32(ch.codePointAt(0) ?? 32) + .u16(ox) + .u16(oy) + .u8(FONT_CELL) + .u8(FONT_CELL) + .u8(advanceOf(ch)) + .u8(0); + }); + return w.finish(); +} + +// --------------------------------------------------------------------------- +// Sections +// --------------------------------------------------------------------------- + +function sectionTilesets(): Uint8Array { + const w = new Writer(); + w.u16(1).u16(0); // one tileset + const ids = Object.keys(BLOCK_TILES).map(Number).sort((a, b) => a - b); + const count = (ids[ids.length - 1] ?? 0) + 1; + w.u16(count).u16(0); + for (let id = 0; id < count; id++) { + const tiles = BLOCK_TILES[id] ?? Array.from({ length: 16 }, () => 0); + for (let i = 0; i < 16; i++) w.u8(tiles[i] ?? 0); + } + w.bytes(behaviorTable()); + return w.finish(); +} + +function sectionTypes(text: TextTable): Uint8Array { + const w = new Writer(); + w.u16(TYPE_NAMES.length).u16(MATCHUPS.length); + for (let i = 0; i < TYPE_NAMES.length; i++) { + w.u8(TYPE_CATEGORY[i] ?? 0).u8(0).u16(text.key(TYPE_NAMES[i]!)); + } + for (const [atk, def, mult] of MATCHUPS) w.u8(atk).u8(def).u16(mult); + return w.finish(); +} + +function sectionSpecies(text: TextTable): Uint8Array { + // The learnset pool is shared: every species points at a slice of it. + const pool: Array<[number, number]> = []; + const offsets: number[] = []; + for (const s of SPECIES) { + offsets.push(pool.length); + for (const entry of s.learnset) pool.push(entry); + } + + const w = new Writer(); + w.u16(SPECIES.length).u16(pool.length); + SPECIES.forEach((s, i) => { + w.u16(s.id) + .u8(s.hp) + .u8(s.atk) + .u8(s.def) + .u8(s.spd) + .u8(s.spc) + .u8(s.type1) + .u8(s.type2 ?? s.type1) + .u8(s.catchRate) + .u16(s.baseExp) + .u8(s.growth) + .u8(i * 2) // front portrait index + .u8(i * 2 + 1) // back portrait index + .u8(i * 2) // icon reuses the front cell + .u16(text.key(s.name)) + .u16(text.key(s.dex)) + .u8(s.learnset.length) + .u8(s.evolveInto ? 1 : 0) // evolve::LEVEL + .u16(s.evolveLevel ?? 0) + .u16(s.evolveInto ?? 0) + .u16(offsets[i]!) + .u32(0); + }); + for (const [level, move] of pool) w.u16(level).u16(move); + return w.finish(); +} + +function sectionMoves(text: TextTable): Uint8Array { + const w = new Writer(); + w.u16(MOVES.length).u16(0); + for (const m of MOVES) { + w.u16(m.id) + .u8(m.type) + .u8(m.power) + .u8(m.accuracy) + .u8(m.pp) + // No explicit category means "decide by type", which the core spells as + // the type's own category — so write that rather than a sentinel. + .u8(m.category ?? TYPE_CATEGORY[m.type] ?? 0) + .u8(m.effect ?? 0) + .u8(m.chance ?? 0) + .u8(m.flags ?? 0) + .u16(text.key(m.name)) + .u16(text.key(m.desc)) + .u16(0); + } + return w.finish(); +} + +function sectionItems(text: TextTable): Uint8Array { + const w = new Writer(); + w.u16(ITEMS.length).u16(0); + for (const it of ITEMS) { + w.u16(it.id) + .u16(text.key(it.name)) + .u16(text.key(it.desc)) + .u8(it.kind) + .u8(it.param) + .u16(it.price) + .u16(0); + } + return w.finish(); +} + +function sectionMaps(text: TextTable, scriptKeys: Map): Uint8Array { + const w = new Writer(1 << 16); + w.u16(MAPS.length).u16(0); + const tableAt = w.length; + for (let i = 0; i < MAPS.length; i++) w.u32(0); // patched below + + MAPS.forEach((m, i) => { + w.align(4); + w.patchU32(tableAt + i * 4, w.length); + const { w: bw, h: bh, blocks } = blocksOf(m); + const warps = m.warps ?? []; + const signs = m.signs ?? []; + const actors = m.actors ?? []; + const slots = m.slots ?? []; + if (slots.length !== 0 && slots.length !== SLOT_COUNT) { + throw new Error(`map ${m.name}: needs exactly ${SLOT_COUNT} encounter slots`); + } + const conn = m.conn ?? [-1, -1, -1, -1]; + const connOff = m.connOff ?? [0, 0, 0, 0]; + + w.u16(m.id) + .u8(bw) + .u8(bh) + .u8(m.tileset ?? 0) + .u8(m.border) + .u8(m.indoor ? 1 : 0) + .u8(m.encounterRate ?? 0) + .u16(text.key(m.name)) + .u16(m.music ?? 0) + .u8(warps.length) + .u8(signs.length) + .u8(actors.length) + .u8(slots.length); + for (const c of conn) w.i16(c); + for (const o of connOff) w.i16(o); + + for (const b of blocks) w.u8(b); + for (const p of warps) w.u8(p.x).u8(p.y).u16(p.destMap).u8(p.destWarp).u8(p.dir).u16(0); + for (const s of signs) w.u8(s.x).u8(s.y).u16(text.key(s.text)); + for (const a of actors) { + // An actor's text key doubles as its script key: the core's talk + // dispatch looks for a script filed under exactly this id first. + const key = a.script + ? scriptKeys.get(a.script) ?? + (() => { + throw new Error(`actor references unknown script '${a.script}'`); + })() + : a.text + ? text.key(a.text) + : 0; + w.u8(a.x) + .u8(a.y) + .u8(a.dir) + .u8(a.behavior) + .u8(a.sprite) + .u8(0) + .u16(key) + .i16(a.trainer ?? -1) + .u16(a.flagGate ?? 0xffff); + } + for (const [species, level] of slots) w.u16(species).u8(level).u8(0); + }); + return w.finish(); +} + +function sectionTrainers(text: TextTable): Uint8Array { + const w = new Writer(); + w.u16(TRAINERS.length).u16(0); + const tableAt = w.length; + for (let i = 0; i < TRAINERS.length; i++) w.u32(0); + TRAINERS.forEach((t, i) => { + w.align(4); + w.patchU32(tableAt + i * 4, w.length); + w.u16(t.id).u16(text.key(t.name)).u8(t.aiClass).u8(t.party.length).u16(t.reward); + for (const p of t.party) { + w.u16(p.species).u8(p.level).u8(0); + for (let k = 0; k < 4; k++) w.u16(p.moves[k] ?? 0); + } + }); + return w.finish(); +} + +/** + * Assemble one script into the VM's bytecode. + * + * Two passes: the first sizes every row so labels can resolve to byte offsets, + * the second emits. Label *rows* are emitted too (as a zero-argument no-op) so + * a row index maps one-to-one onto an instruction, which keeps the offsets + * honest and the disassembly readable. + */ +export function assembleScript(rows: Row[], text: TextTable): Uint8Array { + const labelNames: string[] = []; + const labelRow = new Map(); + rows.forEach((row, i) => { + if (row[0] === VERB.label) { + const name = row[1]; + if (typeof name !== "string") throw new Error("label rows need a name"); + if (labelRow.has(name)) throw new Error(`duplicate label '${name}'`); + labelRow.set(name, i); + labelNames.push(name); + } + }); + const labelIndex = new Map(labelNames.map((n, i) => [n, i])); + + // Resolve arguments now so both passes agree on the row widths. + const resolved: Array<{ verb: number; args: number[] }> = rows.map((row) => { + const verb = row[0]; + const args: number[] = []; + for (let i = 1; i < row.length; i++) { + const a = row[i]!; + if (typeof a === "number") args.push(a); + else if (typeof a === "string") { + // A label row's name is metadata, not an argument. + if (verb === VERB.label) continue; + args.push(text.key(a)); + } else { + const idx = labelIndex.get(a.label); + if (idx === undefined) throw new Error(`jump to unknown label '${a.label}'`); + args.push(idx); + } + } + return { verb, args }; + }); + + const headerSize = 8; + const offsets: number[] = []; + let at = headerSize + labelNames.length * 4; + for (const r of resolved) { + offsets.push(at); + at += 2 + r.args.length * 4; + } + + const w = new Writer(at + 16); + w.u16(SCRIPT_VERSION).u16(resolved.length).u16(labelNames.length).u16(0); + for (const name of labelNames) { + const row = labelRow.get(name)!; + w.u32(offsets[row]!); + } + for (const r of resolved) { + w.u8(r.verb).u8(r.args.length); + for (const a of r.args) w.i32(a); + } + return w.finish(); +} + +function sectionScripts(text: TextTable, scriptKeys: Map): Uint8Array { + const bodies = SCRIPTS.map((s) => ({ + key: scriptKeys.get(s.name)!, + bytes: assembleScript(s.rows, text), + })); + const w = new Writer(1 << 14); + w.u16(bodies.length).u16(0); + const tableAt = w.length; + for (let i = 0; i < bodies.length; i++) w.u16(0).u16(0).u32(0).u32(0); + bodies.forEach((b, i) => { + w.align(4); + const at = w.length; + w.bytes(b.bytes); + const entry = tableAt + i * 12; + // u16 nameKey | u16 reserved | u32 offset | u32 length + new DataView(new ArrayBuffer(0)); // (no-op; kept for symmetry with reads) + w.patchU32(entry + 4, at); + w.patchU32(entry + 8, b.bytes.length); + // The key halves are patched by hand since Writer only back-patches u32. + patchU16(w, entry, b.key); + }); + return w.finish(); +} + +/** Writer has no u16 back-patch; scripts are the only caller that needs one. */ +function patchU16(w: Writer, at: number, v: number): void { + // Read-modify-write the containing u32 so we do not need a second accessor. + const buf = (w as unknown as { buf: Uint8Array }).buf; + buf[at] = v & 0xff; + buf[at + 1] = (v >> 8) & 0xff; +} + +function sectionText(text: TextTable): Uint8Array { + const strings = text.all(); + const encoder = new TextEncoder(); + const encoded = strings.map((s) => encoder.encode(s)); + const w = new Writer(1 << 15); + w.u16(encoded.length).u16(0); + const tableAt = w.length; + for (let i = 0; i < encoded.length; i++) w.u32(0).u32(0); + encoded.forEach((bytes, i) => { + const at = w.length; + w.bytes(bytes); + w.patchU32(tableAt + i * 8, at); + w.patchU32(tableAt + i * 8 + 4, bytes.length); + }); + return w.finish(); +} + +// --------------------------------------------------------------------------- +// Container +// --------------------------------------------------------------------------- + +interface Section { + tag: number; + count: number; + payload: Uint8Array; +} + +/** Cook the whole game into one MONPAK. */ +export function cook(): Uint8Array { + const built = buildText(); + const { text, scriptKeys } = built; + + // Sections that intern text must run before TEXT is serialized. + const sections: Section[] = [ + { tag: MONPAK_TAG.palette, count: 256, payload: packPalette() }, + { tag: MONPAK_TAG.atlas, count: 0, payload: sectionAtlas(buildPages()) }, + { tag: MONPAK_TAG.tileset, count: 1, payload: sectionTilesets() }, + { tag: MONPAK_TAG.types, count: TYPE_NAMES.length, payload: sectionTypes(text) }, + { tag: MONPAK_TAG.species, count: SPECIES.length, payload: sectionSpecies(text) }, + { tag: MONPAK_TAG.moves, count: MOVES.length, payload: sectionMoves(text) }, + { tag: MONPAK_TAG.items, count: ITEMS.length, payload: sectionItems(text) }, + { tag: MONPAK_TAG.trainers, count: TRAINERS.length, payload: sectionTrainers(text) }, + { tag: MONPAK_TAG.scripts, count: SCRIPTS.length, payload: sectionScripts(text, scriptKeys) }, + { tag: MONPAK_TAG.maps, count: MAPS.length, payload: sectionMaps(text, scriptKeys) }, + { tag: MONPAK_TAG.font, count: characters().length, payload: sectionFont() }, + ]; + // TEXT goes last so every key interned above is included. + sections.push({ tag: MONPAK_TAG.text, count: text.size, payload: sectionText(text) }); + + const tableBytes = sections.length * 16; + let cursor = MONPAK_HEADER_SIZE + tableBytes; + const placed = sections.map((s) => { + cursor = align(cursor, MONPAK_ALIGN); + const at = cursor; + cursor += s.payload.length; + return { ...s, offset: at }; + }); + const total = align(cursor, MONPAK_ALIGN); + + const out = new Uint8Array(total); + const dv = new DataView(out.buffer); + dv.setUint32(0, MONPAK_MAGIC, true); + dv.setUint16(4, MONPAK_VERSION, true); + dv.setUint16(6, sections.length, true); + dv.setUint32(8, total, true); + dv.setUint32(12, 0, true); + placed.forEach((s, i) => { + const at = MONPAK_HEADER_SIZE + i * 16; + dv.setUint32(at, s.tag, true); + dv.setUint32(at + 4, s.offset, true); + dv.setUint32(at + 8, s.payload.length, true); + dv.setUint32(at + 12, s.count, true); + out.set(s.payload, s.offset); + }); + return out; +} + +function align(v: number, n: number): number { + return v % n === 0 ? v : v + (n - (v % n)); +} + +/** A short summary of what was cooked, for the build log. */ +export function summary(pak: Uint8Array): string { + const kb = (n: number) => `${(n / 1024).toFixed(1)} kB`; + return [ + `SPARKWOOD content: ${kb(pak.length)}`, + ` ${SPECIES.length} species, ${MOVES.length} moves, ${TYPE_NAMES.length} types`, + ` ${MAPS.length} maps, ${TRAINERS.length} trainers, ${SCRIPTS.length} scripts`, + ` ${CAST.length} actor sheets (${SPRITE_PX}px), ${SPECIES.length * 2} portraits (${PORTRAIT_PX}px)`, + ].join("\n"); +} + +if (import.meta.main) { + const out = Bun.argv[2] ?? new URL("../../dist/sparkwood.monpak", import.meta.url).pathname; + const pak = cook(); + await Bun.write(out, pak); + console.log(summary(pak)); + console.log(`wrote ${out}`); +} diff --git a/apps/mon/sdk.ts b/apps/mon/sdk.ts new file mode 100644 index 00000000..f8b50761 --- /dev/null +++ b/apps/mon/sdk.ts @@ -0,0 +1,325 @@ +// The `mon` guest SDK — the surface expressed in its domain's own algebra. +// +// RUNTIMES.md discipline #4: raw surfaces are wire protocols, and each one +// ships an SDK in the shape its domain actually wants. A creature RPG's shape +// is *registries and hooks*: content you declare, and facts you react to. So +// the API here is `mon.on("encounter", …)` and `mon.party()`, not +// `mon.op(115)`. +// +// ## What this binds to +// +// `globalThis.mon` is installed by whichever host is running the guest, and is +// a thin marshalling shim over the core's single op dispatcher +// (`pocketmon-core/src/surface.rs`). One function on the Rust side, one object +// here — no hand-written trampoline per op to keep in sync. +// +// ## Law 1 +// +// Reads are mirrored. `party()` and `world()` decode a packed snapshot the +// core hands over on request; anything drawn every frame is drawn by the core +// from its own state, and the guest never touches it. A guest that polled a +// view per frame would be paying the boundary cost the whole design exists to +// avoid. + +import { MON_BTN, MON_EVENT, MON_OP, VIEW } from "../../contracts/spec/mon-spec.ts"; + +// --------------------------------------------------------------------------- +// The host object +// --------------------------------------------------------------------------- + +/** The raw surface a host installs. Every call is one op. */ +export interface MonHost { + op(code: number, ...args: Array): unknown; +} + +declare const globalThis: { mon?: MonHost } & Record; + +function host(): MonHost { + const h = globalThis.mon; + if (!h) throw new Error("the `mon` surface is not mounted on this host"); + return h; +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +/** A decoded fact from the core. */ +export interface MonEventRecord { + kind: number; + a: number; + b: number; + c: number; + d: number; +} + +/** Event names, in the shape a handler registration wants. */ +export type EventName = keyof typeof MON_EVENT; + +const EVENT_BY_CODE = new Map( + Object.entries(MON_EVENT).map(([name, code]) => [code, name as EventName]), +); + +/** Bytes per packed event record — mirrors `spec::EVENT_SIZE`. */ +const EVENT_SIZE = 16; + +/** Decode the packed batch `events()` returns. */ +export function decodeEvents(buffer: ArrayBuffer): MonEventRecord[] { + const dv = new DataView(buffer); + const out: MonEventRecord[] = []; + for (let at = 0; at + EVENT_SIZE <= buffer.byteLength; at += EVENT_SIZE) { + out.push({ + kind: dv.getUint16(at, true), + a: dv.getUint16(at + 2, true), + b: dv.getInt32(at + 4, true), + c: dv.getInt32(at + 8, true), + d: dv.getInt32(at + 12, true), + }); + } + return out; +} + +// --------------------------------------------------------------------------- +// Views +// --------------------------------------------------------------------------- + +export interface WorldView { + map: number; + x: number; + y: number; + dir: number; + mode: number; + steps: number; + lastOutdoor: number; +} + +export interface PartyMember { + species: number; + level: number; + status: number; + hp: number; + maxHp: number; +} + +export interface BattleView { + active: boolean; + phase: number; + own: { species: number; hp: number; maxHp: number; level: number }; + foe: { species: number; hp: number; maxHp: number; level: number }; +} + +function decodeWorld(buffer: ArrayBuffer): WorldView { + const dv = new DataView(buffer); + return { + map: dv.getUint16(0, true), + x: dv.getInt16(2, true), + y: dv.getInt16(4, true), + dir: dv.getUint8(6), + mode: dv.getUint8(7), + steps: dv.getUint32(8, true), + lastOutdoor: dv.getUint16(12, true), + }; +} + +function decodeParty(buffer: ArrayBuffer): PartyMember[] { + const dv = new DataView(buffer); + const n = buffer.byteLength > 0 ? dv.getUint8(0) : 0; + const out: PartyMember[] = []; + for (let i = 0; i < n; i++) { + const at = 1 + i * 8; + if (at + 8 > buffer.byteLength) break; + out.push({ + species: dv.getUint16(at, true), + level: dv.getUint8(at + 2), + status: dv.getUint8(at + 3), + hp: dv.getUint16(at + 4, true), + maxHp: dv.getUint16(at + 6, true), + }); + } + return out; +} + +function decodeBattle(buffer: ArrayBuffer): BattleView { + const empty = { species: 0, hp: 0, maxHp: 0, level: 0 }; + if (buffer.byteLength === 0) { + return { active: false, phase: 0, own: empty, foe: empty }; + } + const dv = new DataView(buffer); + if (dv.getUint8(0) === 0) return { active: false, phase: 0, own: empty, foe: empty }; + return { + active: true, + phase: dv.getUint8(1), + own: { + species: dv.getUint16(2, true), + hp: dv.getUint16(4, true), + maxHp: dv.getUint16(6, true), + level: dv.getUint8(14), + }, + foe: { + species: dv.getUint16(8, true), + hp: dv.getUint16(10, true), + maxHp: dv.getUint16(12, true), + level: dv.getUint8(15), + }, + }; +} + +// --------------------------------------------------------------------------- +// The SDK +// --------------------------------------------------------------------------- + +type Handler = (event: MonEventRecord) => void; + +/** + * A guest program: content plus reactions. + * + * Construct one, register handlers, and call [`Mon.pump`] once per tick. The + * base game is written against exactly this — RUNTIMES.md discipline #5, "let + * the base game be the first mod": if the shipped game needs something the SDK + * cannot say, the surface is too weak and the surface is what gets fixed. + */ +export class Mon { + private readonly handlers = new Map(); + + /** Register a reaction. Several handlers per event run in order. */ + on(event: EventName, handler: Handler): this { + const list = this.handlers.get(event); + if (list) list.push(handler); + else this.handlers.set(event, [handler]); + return this; + } + + /** + * Drain this tick's facts and run their handlers. + * + * Call once per frame, before anything else: the core has already acted on + * its own events by now (a wild encounter has already opened a battle), so a + * handler is reacting to something that happened, not vetoing it. + */ + pump(): MonEventRecord[] { + const raw = host().op(MON_OP.events) as ArrayBuffer | undefined; + if (!raw || raw.byteLength === 0) return []; + const events = decodeEvents(raw); + for (const event of events) { + const name = EVENT_BY_CODE.get(event.kind); + if (!name) continue; // a fact from a newer core: ignore, do not throw + for (const handler of this.handlers.get(name) ?? []) handler(event); + } + return events; + } + + // --- content ------------------------------------------------------------ + + /** Upload a cooked MONPAK. */ + loadContent(pak: ArrayBuffer): boolean { + return Boolean(host().op(MON_OP.loadContent, pak)); + } + + // --- world -------------------------------------------------------------- + + enterMap(map: number, x: number, y: number, dir = 0): void { + host().op(MON_OP.enterMap, map, x, y, dir); + } + + warpTo(map: number, x: number, y: number, dir = 0, fade = true): void { + host().op(MON_OP.warpTo, map, x, y, dir, fade ? 1 : 0); + } + + /** Push a dialogue box. Returns a handle that comes back as `textDone`. */ + say(text: string): number { + return Number(host().op(MON_OP.showText, text)); + } + + /** Ask a question. Returns a handle that comes back as `choiceDone`. */ + ask(prompt: string, options: string[] = ["YES", "NO"]): number { + return Number(host().op(MON_OP.showChoice, prompt, options.join("\n"))); + } + + flag(id: number): boolean { + return Boolean(host().op(MON_OP.getFlag, id)); + } + + setFlag(id: number, value = true): void { + host().op(MON_OP.setFlag, id, value ? 1 : 0); + } + + playMusic(id: number): void { + host().op(MON_OP.playMusic, id); + } + + stopMusic(): void { + host().op(MON_OP.stopMusic); + } + + // --- party -------------------------------------------------------------- + + give(species: number, level: number): number { + return Number(host().op(MON_OP.givemon, species, level)); + } + + heal(): void { + host().op(MON_OP.healParty); + } + + giveItem(item: number, qty = 1): boolean { + return Boolean(host().op(MON_OP.giveItem, item, qty)); + } + + takeItem(item: number, qty = 1): boolean { + return Boolean(host().op(MON_OP.takeItem, item, qty)); + } + + // --- battle ------------------------------------------------------------- + + startWild(species: number, level: number): boolean { + return Boolean(host().op(MON_OP.startWild, species, level)); + } + + startTrainer(id: number): boolean { + return Boolean(host().op(MON_OP.startTrainer, id)); + } + + chooseAction(action: number): void { + host().op(MON_OP.chooseAction, action); + } + + chooseMove(index: number): void { + host().op(MON_OP.chooseMove, index); + } + + // --- reads (cold path; never per-frame) --------------------------------- + + world(): WorldView { + return decodeWorld((host().op(MON_OP.view, VIEW.world) as ArrayBuffer) ?? new ArrayBuffer(0)); + } + + party(): PartyMember[] { + return decodeParty((host().op(MON_OP.view, VIEW.party) as ArrayBuffer) ?? new ArrayBuffer(0)); + } + + battle(): BattleView { + return decodeBattle((host().op(MON_OP.view, VIEW.battle) as ArrayBuffer) ?? new ArrayBuffer(0)); + } + + /** A string from the content's table. */ + text(key: number): string { + return String(host().op(MON_OP.text, key) ?? ""); + } + + // --- system -------------------------------------------------------------- + + save(): ArrayBuffer { + return (host().op(MON_OP.save) as ArrayBuffer) ?? new ArrayBuffer(0); + } + + load(blob: ArrayBuffer): boolean { + return Boolean(host().op(MON_OP.load, blob)); + } + + seed(lo: number, hi = 0): void { + host().op(MON_OP.seed, lo, hi); + } +} + +/** Button bits, re-exported so a guest need not reach into the spec. */ +export const BTN = MON_BTN; diff --git a/apps/mon/tapes/story.tape b/apps/mon/tapes/story.tape new file mode 100644 index 00000000..65873d9d --- /dev/null +++ b/apps/mon/tapes/story.tape @@ -0,0 +1,73 @@ +# SPARKWOOD acceptance run. +# +# Wake up at home, cross the village, take a partner from the professor, walk +# out to Route One and win the first wild battle. The whole opening. +# +# Tapes describe intent, never frame counts: +# walk hold a direction until that many steps land +# press one edge-detected button press +# clear [presses] press A until nothing is waiting to be read +# grind
[cap] pace between two directions until a battle starts +# wait idle (fades, message holds) +# mark capture + hash the frame + +mark 00-wake + +# --- out of the house ---------------------------------------------------- +walk d 4 +walk r 4 +wait 40 +mark 01-village + +# --- across the village to the lab --------------------------------------- +walk d 4 +walk r 6 +walk u 4 +wait 40 +mark 02-inside-lab + +# --- the professor, and a partner ---------------------------------------- +walk u 2 +walk l 2 +press u +mark 03-facing-professor +press a +# One `clear` runs the professor's whole script: two pages of greeting, the +# choice (A on the first option takes EMBERKIT), and the parting gift. Pressing +# A again afterwards would just start the conversation over — the player is +# still standing nose to nose with him. +clear 40 +mark 04-partner-in-hand + +# --- out of the lab, north out of the village ---------------------------- +walk d 2 +walk r 2 +wait 40 +mark 06-back-outside +walk d 4 +walk l 3 +walk u 9 +walk u 1 +wait 40 +mark 07-route-one + +# --- the first wild battle ------------------------------------------------ +walk u 3 +mark 08-in-the-grass +grind ud 900 +wait 20 +mark 09-wild-appears +clear +mark 10-action-menu +press a +wait 20 +mark 11-move-menu +fight +mark 12-battle-over + +# --- and again, to prove the loop repeats --------------------------------- +grind ud 900 +wait 20 +mark 13-second-encounter +fight +mark 14-still-standing diff --git a/contracts/spec/gen-mon-rust.ts b/contracts/spec/gen-mon-rust.ts new file mode 100644 index 00000000..9a685850 --- /dev/null +++ b/contracts/spec/gen-mon-rust.ts @@ -0,0 +1,426 @@ +// Deterministic codegen: contracts/spec/mon-spec.ts -> +// engine/pocketmon/crates/pocketmon-core/src/spec.rs +// +// Run from PocketJS/: bun contracts/spec/gen-mon-rust.ts +// +// tests/mon-contract.test.ts imports generateMonRust() and byte-compares its +// output against the committed spec.rs, so the generated file can never drift +// from mon-spec.ts. Keep this generator free of anything non-deterministic +// (no dates, no env, no object-key sorting surprises — insertion order only). + +import { + ACTION, + ACTORS_MAX, + ACTOR_SIZE, + ATLAS_PAGE_HEADER_SIZE, + AUDIO_ENTRY_SIZE, + BAG_MAX, + BALL_RATE, + BEHAVIOR, + BLOCK_CELLS, + BLOCK_PX, + BLOCK_TILES, + BOX_COUNT, + BOX_SIZE, + CATCH_STATUS_BONUS, + CATEGORY, + CELL, + CELL_PX, + DAMAGE_CLAMP, + DIR, + EFFECT, + ENCOUNTER_BUCKETS, + EVENT_CAP, + EVENT_SIZE, + FLAG_COUNT, + FNV1A_OFFSET_BASIS, + FNV1A_PRIME, + FONT_HEADER_SIZE, + GB_H, + GB_W, + GLYPH_SIZE, + GROWTH, + ITEM_KIND, + ITEM_SIZE, + LEARN_SIZE, + LEVEL_MAX, + MATCHUP_SIZE, + MON_BTN, + MAP_FLAG_DARK, + MAP_FLAG_INDOOR, + MAP_FLAG_NO_ESCAPE, + MAP_HEADER_SIZE, + MODE, + MONPAK_ALIGN, + MONPAK_ENTRY_SIZE, + MONPAK_HEADER_SIZE, + MONPAK_MAGIC, + MONPAK_TAG, + MONPAK_VERSION, + MON_EVENT, + MON_OP, + MOVES_MAX, + MOVE_FLAG_CHARGE, + MOVE_FLAG_HIGH_CRIT, + MOVE_FLAG_MULTI_HIT, + MOVE_FLAG_PRIORITY, + MOVE_FLAG_RECHARGE, + MOVE_SIZE, + OUTCOME, + PAGE_MAX, + PAGE_PX, + PALETTE_BYTES, + PALETTE_ENTRIES, + PARTY_MAX, + PHASE, + QUAD_FLAG_FLIP_X, + QUAD_FLAG_FLIP_Y, + QUAD_SIZE, + RAND_MAX, + RAND_MIN, + RECT_SIZE, + SAVE_HEADER_SIZE, + SAVE_MAGIC, + SAVE_VERSION, + SCRIPT_ENTRY_SIZE, + SCRIPT_HEADER_SIZE, + SCRIPT_VERSION, + SECTION_HEADER_SIZE, + SIGN_SIZE, + SLOT_COUNT, + SLOT_SIZE, + SPECIES_SECTION_HEADER_SIZE, + SPECIES_SIZE, + STAB_DEN, + STAB_NUM, + STAGE_MAX, + STAGE_MIN, + STAGE_MULT, + STAT_MAX, + STAT_SCALE_LIMIT, + STATUS, + TEXT_ENTRY_SIZE, + TICK_HZ, + TILESET_BLOCK_SIZE, + TILESET_HEADER_SIZE, + TILE_BEHAVIOR_BYTES, + TILE_PX, + TINT_NONE, + TRAINER_HEADER_SIZE, + TRAINER_MON_SIZE, + TRAINER_PARTY_MAX, + TYPE_SCALE, + TYPE_SECTION_HEADER_SIZE, + TYPE_SIZE, + VERB, + VIEW, + VIEW_H, + VIEW_W, + WARP_SIZE, +} from "./mon-spec.ts"; + +function hex(n: number, pad = 8): string { + return "0x" + (n >>> 0).toString(16).padStart(pad, "0"); +} + +/** SCREAMING_SNAKE from a camelCase spec key. */ +function screaming(name: string): string { + return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); +} + +/** Emit a `pub mod { pub const K: = v; }` block from a record. */ +function constMod( + put: (s?: string) => void, + name: string, + ty: string, + table: Record, + doc: string[], +) { + for (const line of doc) put(`/// ${line}`); + put(`pub mod ${name} {`); + for (const [k, v] of Object.entries(table)) { + put(` pub const ${screaming(k)}: ${ty} = ${v};`); + } + put("}"); + put(""); +} + +export function generateMonRust(): string { + const L: string[] = []; + const put = (s = "") => L.push(s); + + put("//! GENERATED — do not edit; run `bun contracts/spec/gen-mon-rust.ts` (from PocketJS/)."); + put("//!"); + put("//! Source of truth: contracts/spec/mon-spec.ts — every constant here mirrors it."); + put("//! tests/mon-contract.test.ts regenerates this file in-memory and byte-compares;"); + put("//! if that fails, run `bun contracts/spec/gen-mon-rust.ts` and commit the result."); + put("//!"); + put("//! See docs/MON.md for the architecture this contract serves."); + put(""); + put("#![allow(dead_code)]"); + put("#![allow(clippy::all)]"); + put(""); + + // --- geometry ------------------------------------------------------------- + put("// ---------------------------------------------------------------------------"); + put("// Geometry — the three coordinate units (docs/MON.md §4)"); + put("// ---------------------------------------------------------------------------"); + put(""); + put("/// Graphics unit: an 8x8 pixel tile."); + put(`pub const TILE_PX: i32 = ${TILE_PX};`); + put("/// Walk-grid unit: a 16x16 pixel cell = 2x2 tiles."); + put(`pub const CELL_PX: i32 = ${CELL_PX};`); + put("/// Layout unit: a 32x32 pixel block = 2x2 cells = 4x4 tiles."); + put(`pub const BLOCK_PX: i32 = ${BLOCK_PX};`); + put("/// Tiles per block edge; a block's tile array is BLOCK_TILES^2 entries."); + put(`pub const BLOCK_TILES: usize = ${BLOCK_TILES};`); + put("/// Cells per block edge."); + put(`pub const BLOCK_CELLS: i32 = ${BLOCK_CELLS};`); + put("/// The logical view, in GB-scale pixels (the PSP host renders it 2x)."); + put(`pub const VIEW_W: i32 = ${VIEW_W};`); + put(`pub const VIEW_H: i32 = ${VIEW_H};`); + put("/// The original handheld framing, for reference and 1x hosts."); + put(`pub const GB_W: i32 = ${GB_W};`); + put(`pub const GB_H: i32 = ${GB_H};`); + put("/// Fixed simulation step."); + put(`pub const TICK_HZ: u32 = ${TICK_HZ};`); + put(""); + constMod(put, "btn", "u32", MON_BTN, [ + "The core's abstract button set; hosts map their physical buttons onto", + "it, so one input tape replays on every host.", + ]); + + // --- limits --------------------------------------------------------------- + put("// ---------------------------------------------------------------------------"); + put("// Party / bag / box limits"); + put("// ---------------------------------------------------------------------------"); + put(""); + put(`pub const PARTY_MAX: usize = ${PARTY_MAX};`); + put(`pub const MOVES_MAX: usize = ${MOVES_MAX};`); + put(`pub const BOX_COUNT: usize = ${BOX_COUNT};`); + put(`pub const BOX_SIZE: usize = ${BOX_SIZE};`); + put(`pub const BAG_MAX: usize = ${BAG_MAX};`); + put("/// Max simultaneously-loaded actors on a map (player is slot 0)."); + put(`pub const ACTORS_MAX: usize = ${ACTORS_MAX};`); + put(`pub const LEVEL_MAX: u32 = ${LEVEL_MAX};`); + put(`pub const STAGE_MIN: i32 = ${STAGE_MIN};`); + put(`pub const STAGE_MAX: i32 = ${STAGE_MAX};`); + put(""); + + // --- enums ---------------------------------------------------------------- + put("// ---------------------------------------------------------------------------"); + put("// Enums — closed vocabularies the core and guest share"); + put("// ---------------------------------------------------------------------------"); + put(""); + constMod(put, "dir", "u8", DIR, ["Facing / movement direction; matches walk-sheet frame order."]); + constMod(put, "cell", "u8", CELL, ["What a cell does, derived from its bottom-left 8x8 tile."]); + constMod(put, "status", "u8", STATUS, ["Persistent status conditions; `NONE` stays 0."]); + constMod(put, "category", "u8", CATEGORY, [ + "Damage category. Gen 1 splits physical/special by the move's TYPE;", + "a type record carries its category and a move may override it.", + ]); + constMod(put, "growth", "u8", GROWTH, ["Experience growth curves (see growth.rs)."]); + constMod(put, "mode", "u8", MODE, ["The top-level mode the core is in."]); + constMod(put, "phase", "u8", PHASE, ["Battle phase — what the core is waiting for."]); + constMod(put, "action", "u8", ACTION, ["The player's battle action."]); + constMod(put, "outcome", "u8", OUTCOME, ["How a battle finished."]); + constMod(put, "behavior", "u8", BEHAVIOR, ["Actor movement behavior for NPCs."]); + + // --- ops / events --------------------------------------------------------- + put("// ---------------------------------------------------------------------------"); + put("// The `mon` surface: ops (guest -> core) and events (core -> guest)"); + put("// ---------------------------------------------------------------------------"); + put(""); + constMod(put, "op", "u32", MON_OP, [ + "Guest -> core intent. APPEND ONLY: never renumber, never reuse.", + "Signatures are documented in contracts/spec/mon-spec.ts.", + ]); + constMod(put, "event", "u16", MON_EVENT, [ + "Core -> guest facts, drained as one batch per tick. APPEND ONLY.", + ]); + put("/// Bytes per packed event record: u16 kind | u16 a | i32 b | i32 c | i32 d."); + put(`pub const EVENT_SIZE: usize = ${EVENT_SIZE};`); + put("/// Max events buffered in one tick; overflow drops the tail and sets a stat."); + put(`pub const EVENT_CAP: usize = ${EVENT_CAP};`); + put(""); + constMod(put, "view", "u32", VIEW, ["`view(kind)` packed snapshots for menu rendering."]); + + // --- MONPAK --------------------------------------------------------------- + put("// ---------------------------------------------------------------------------"); + put("// MONPAK — the cooked content container"); + put("// ---------------------------------------------------------------------------"); + put(""); + put("pub mod monpak {"); + put(` pub const MAGIC: u32 = ${hex(MONPAK_MAGIC)}; // 'MONP' LE`); + put(` pub const VERSION: u16 = ${MONPAK_VERSION};`); + put(` pub const HEADER_SIZE: usize = ${MONPAK_HEADER_SIZE};`); + put(` pub const ENTRY_SIZE: usize = ${MONPAK_ENTRY_SIZE};`); + put(` pub const ALIGN: usize = ${MONPAK_ALIGN};`); + put(""); + put(" /// Section tags (4CC, LE u32)."); + for (const [k, v] of Object.entries(MONPAK_TAG)) { + put(` pub const TAG_${screaming(k)}: u32 = ${hex(v)};`); + } + put("}"); + put(""); + + // --- section payload layouts ---------------------------------------------- + put("// ---------------------------------------------------------------------------"); + put("// Section payload layouts"); + put("// ---------------------------------------------------------------------------"); + put(""); + put("/// Bytes of per-section header preceding every payload's records."); + put(`pub const SECTION_HEADER_SIZE: usize = ${SECTION_HEADER_SIZE};`); + put("/// ATLS page header: u16 w, u16 h, u32 byteLen, then w*h CLUT8 pixels."); + put(`pub const ATLAS_PAGE_HEADER_SIZE: usize = ${ATLAS_PAGE_HEADER_SIZE};`); + put(`pub const PALETTE_ENTRIES: usize = ${PALETTE_ENTRIES};`); + put(`pub const PALETTE_BYTES: usize = ${PALETTE_BYTES};`); + put("/// TLES per-tileset header, block record size, and the tile behavior table."); + put(`pub const TILESET_HEADER_SIZE: usize = ${TILESET_HEADER_SIZE};`); + put(`pub const TILESET_BLOCK_SIZE: usize = ${TILESET_BLOCK_SIZE};`); + put(`pub const TILE_BEHAVIOR_BYTES: usize = ${TILE_BEHAVIOR_BYTES};`); + put("/// SPEC header: u16 count, u16 learnPoolCount."); + put(`pub const SPECIES_SECTION_HEADER_SIZE: usize = ${SPECIES_SECTION_HEADER_SIZE};`); + put("/// One learnset entry: u16 level, u16 moveId."); + put(`pub const LEARN_SIZE: usize = ${LEARN_SIZE};`); + put("/// STYP header: u16 typeCount, u16 matchupCount."); + put(`pub const TYPE_SECTION_HEADER_SIZE: usize = ${TYPE_SECTION_HEADER_SIZE};`); + put(`pub const TYPE_SIZE: usize = ${TYPE_SIZE};`); + put(`pub const MATCHUP_SIZE: usize = ${MATCHUP_SIZE};`); + put(`pub const ITEM_SIZE: usize = ${ITEM_SIZE};`); + put(`pub const FONT_HEADER_SIZE: usize = ${FONT_HEADER_SIZE};`); + put(`pub const GLYPH_SIZE: usize = ${GLYPH_SIZE};`); + put(`pub const SCRIPT_ENTRY_SIZE: usize = ${SCRIPT_ENTRY_SIZE};`); + put("/// A string's index IS its key id; no hashing happens at runtime."); + put(`pub const TEXT_ENTRY_SIZE: usize = ${TEXT_ENTRY_SIZE};`); + put(`pub const AUDIO_ENTRY_SIZE: usize = ${AUDIO_ENTRY_SIZE};`); + put(""); + constMod(put, "item_kind", "u8", ITEM_KIND, ["What an item does when used."]); + + // --- record layouts ------------------------------------------------------- + put("// ---------------------------------------------------------------------------"); + put("// Record layouts (fixed-size, LE) — the cooker writes, the core reads"); + put("// ---------------------------------------------------------------------------"); + put(""); + put("/// SPECIES record byte size (layout in mon-spec.ts)."); + put(`pub const SPECIES_SIZE: usize = ${SPECIES_SIZE};`); + put("/// MOVE record byte size."); + put(`pub const MOVE_SIZE: usize = ${MOVE_SIZE};`); + put("/// Move `flags` bits."); + put(`pub const MOVE_FLAG_HIGH_CRIT: u8 = ${MOVE_FLAG_HIGH_CRIT};`); + put(`pub const MOVE_FLAG_MULTI_HIT: u8 = ${MOVE_FLAG_MULTI_HIT};`); + put(`pub const MOVE_FLAG_CHARGE: u8 = ${MOVE_FLAG_CHARGE};`); + put(`pub const MOVE_FLAG_RECHARGE: u8 = ${MOVE_FLAG_RECHARGE};`); + put("/// Moves first regardless of speed (the \"quick attack\" class)."); + put(`pub const MOVE_FLAG_PRIORITY: u8 = ${MOVE_FLAG_PRIORITY};`); + put(""); + constMod(put, "effect", "u8", EFFECT, [ + "Move effects the core implements natively; anything else raises", + "a `scriptHook` event for the guest. Append-only.", + ]); + put("/// MAP header byte size; the block array and variable sections follow."); + put(`pub const MAP_HEADER_SIZE: usize = ${MAP_HEADER_SIZE};`); + put(`pub const MAP_FLAG_INDOOR: u8 = ${MAP_FLAG_INDOOR};`); + put(`pub const MAP_FLAG_DARK: u8 = ${MAP_FLAG_DARK};`); + put(`pub const MAP_FLAG_NO_ESCAPE: u8 = ${MAP_FLAG_NO_ESCAPE};`); + put(`pub const WARP_SIZE: usize = ${WARP_SIZE};`); + put(`pub const SIGN_SIZE: usize = ${SIGN_SIZE};`); + put(`pub const ACTOR_SIZE: usize = ${ACTOR_SIZE};`); + put(`pub const SLOT_SIZE: usize = ${SLOT_SIZE};`); + put("/// Encounter slots per map."); + put(`pub const SLOT_COUNT: usize = ${SLOT_COUNT};`); + put("/// Cumulative slot thresholds out of 256; rand(0..255) picks the first"); + put("/// bucket it falls under (ported from the upstream wild-encounter buckets)."); + put( + `pub const ENCOUNTER_BUCKETS: [u16; ${ENCOUNTER_BUCKETS.length}] = [${ENCOUNTER_BUCKETS.join(", ")}];`, + ); + put(`pub const TRAINER_HEADER_SIZE: usize = ${TRAINER_HEADER_SIZE};`); + put(`pub const TRAINER_MON_SIZE: usize = ${TRAINER_MON_SIZE};`); + put(`pub const TRAINER_PARTY_MAX: usize = ${TRAINER_PARTY_MAX};`); + put(""); + + // --- script VM ------------------------------------------------------------ + put("// ---------------------------------------------------------------------------"); + put("// Script VM — the compiled command list"); + put("// ---------------------------------------------------------------------------"); + put(""); + put(`pub const SCRIPT_VERSION: u16 = ${SCRIPT_VERSION};`); + put(`pub const SCRIPT_HEADER_SIZE: usize = ${SCRIPT_HEADER_SIZE};`); + put(""); + constMod(put, "verb", "u8", VERB, [ + "The script verb set: the upstream Commands.lua vocabulary trimmed to", + "what the core implements natively. Unknown verbs raise `scriptHook`.", + ]); + + // --- draw list ------------------------------------------------------------ + put("// ---------------------------------------------------------------------------"); + put("// Draw list — the backend-independent frame output"); + put("// ---------------------------------------------------------------------------"); + put(""); + put(`pub const QUAD_SIZE: usize = ${QUAD_SIZE};`); + put(`pub const RECT_SIZE: usize = ${RECT_SIZE};`); + put(`pub const QUAD_FLAG_FLIP_X: u8 = ${QUAD_FLAG_FLIP_X};`); + put(`pub const QUAD_FLAG_FLIP_Y: u8 = ${QUAD_FLAG_FLIP_Y};`); + put("/// Per-vertex tint; this value is \"untinted\"."); + put(`pub const TINT_NONE: u32 = ${hex(TINT_NONE)};`); + put("/// Atlas page edge in pixels (CLUT8: one page is PAGE_PX^2 bytes)."); + put(`pub const PAGE_PX: u32 = ${PAGE_PX};`); + put(`pub const PAGE_MAX: usize = ${PAGE_MAX};`); + put(""); + + // --- save ----------------------------------------------------------------- + put("// ---------------------------------------------------------------------------"); + put("// Save format"); + put("// ---------------------------------------------------------------------------"); + put(""); + put("pub mod save {"); + put(` pub const MAGIC: u32 = ${hex(SAVE_MAGIC)}; // 'MSAV' LE`); + put(` pub const VERSION: u16 = ${SAVE_VERSION};`); + put(` pub const HEADER_SIZE: usize = ${SAVE_HEADER_SIZE};`); + put(" /// FNV-1a 32-bit, the same constants the .pak container uses."); + put(` pub const FNV1A_OFFSET_BASIS: u32 = ${hex(FNV1A_OFFSET_BASIS)};`); + put(` pub const FNV1A_PRIME: u32 = ${hex(FNV1A_PRIME)};`); + put("}"); + put(""); + put("/// Event flags addressable by scripts."); + put(`pub const FLAG_COUNT: usize = ${FLAG_COUNT};`); + put(""); + + // --- battle tuning --------------------------------------------------------- + put("// ---------------------------------------------------------------------------"); + put("// Battle tuning constants (the default ruleset)"); + put("// ---------------------------------------------------------------------------"); + put(""); + put("/// Damage randomization: d = d * rand(RAND_MIN..=RAND_MAX) / 255."); + put(`pub const RAND_MIN: u32 = ${RAND_MIN};`); + put(`pub const RAND_MAX: u32 = ${RAND_MAX};`); + put("/// Stat-stage multipliers x100, indexed `stage + 6` (index 6 = stage 0)."); + put(`pub const STAGE_MULT: [u32; ${STAGE_MULT.length}] = [${STAGE_MULT.join(", ")}];`); + put("/// Stats clamp here after a stage multiplication."); + put(`pub const STAT_MAX: u32 = ${STAT_MAX};`); + put("/// Damage clamps here before the +2, then the random factor applies."); + put(`pub const DAMAGE_CLAMP: u32 = ${DAMAGE_CLAMP};`); + put("/// Both stats are quartered when either exceeds this (byte-overflow rule)."); + put(`pub const STAT_SCALE_LIMIT: u32 = ${STAT_SCALE_LIMIT};`); + put("/// STAB is x3/2."); + put(`pub const STAB_NUM: u32 = ${STAB_NUM};`); + put(`pub const STAB_DEN: u32 = ${STAB_DEN};`); + put("/// Type multipliers are x10 fixed point."); + put(`pub const TYPE_SCALE: u32 = ${TYPE_SCALE};`); + put(`pub const BALL_RATE: [u32; ${BALL_RATE.length}] = [${BALL_RATE.join(", ")}];`); + put("/// Status bonus added to the catch roll."); + put(`pub const CATCH_BONUS_NONE: u32 = ${CATCH_STATUS_BONUS.none};`); + put(`pub const CATCH_BONUS_SLEEP_FREEZE: u32 = ${CATCH_STATUS_BONUS.sleepFreeze};`); + put(`pub const CATCH_BONUS_OTHER: u32 = ${CATCH_STATUS_BONUS.other};`); + + return L.join("\n") + "\n"; +} + +if (import.meta.main) { + const out = new URL( + "../../engine/pocketmon/crates/pocketmon-core/src/spec.rs", + import.meta.url, + ).pathname; + await Bun.write(out, generateMonRust()); + console.log(`wrote ${out}`); +} diff --git a/contracts/spec/mon-spec.ts b/contracts/spec/mon-spec.ts new file mode 100644 index 00000000..e26389e0 --- /dev/null +++ b/contracts/spec/mon-spec.ts @@ -0,0 +1,772 @@ +// Pocket Mon spec — THE single source of truth for the `mon` surface. +// +// Same contract discipline as contracts/spec/spec.ts: everything the Rust core +// (engine/pocketmon/crates/pocketmon-core/), the content cooker +// (apps/mon/cook.ts), the guest SDK (apps/mon/sdk/) and the PSP EBOOT agree on +// is pinned HERE, in plain data. `contracts/spec/gen-mon-rust.ts` deterministically +// generates `engine/pocketmon/crates/pocketmon-core/src/spec.rs` from this file; +// `tests/mon-contract.test.ts` regenerates it in-memory and byte-compares against +// the committed file, so TS and Rust can never drift. +// +// Conventions (inherited, non-negotiable): +// - Little-endian everywhere. +// - Colors are u32 ABGR (0xAABBGGRR) — the PSP GE COLOR_8888 layout. +// - Op codes are append-only: never renumber, never reuse. 0 is reserved. +// +// See docs/MON.md for the architecture this contract serves, including the +// clean-room boundary (no ROM, no extracted content, ever). + +// --------------------------------------------------------------------------- +// Geometry — the three coordinate units (docs/MON.md §4) +// --------------------------------------------------------------------------- + +/** Graphics unit: an 8x8 pixel tile. */ +export const TILE_PX = 8; +/** Walk-grid unit: a 16x16 pixel cell = 2x2 tiles. Every actor/warp/sign coord. */ +export const CELL_PX = 16; +/** Layout unit: a 32x32 pixel block = 2x2 cells = 4x4 tiles. */ +export const BLOCK_PX = 32; +/** Tiles per block edge (4) — a block's tile array is BLOCK_TILES^2 entries. */ +export const BLOCK_TILES = 4; +/** Cells per block edge (2). */ +export const BLOCK_CELLS = 2; + +/** + * The logical view, in GB-scale pixels. The PSP host renders this 2x into + * 480x272 (docs/MON.md §4: no integer scale fits 160x144 on 480x272, so we + * widen the view instead of blurring or letterboxing). 240x136 = 30x17 tiles. + */ +export const VIEW_W = 240; +export const VIEW_H = 136; +/** The original handheld framing, kept for reference and for 1x hosts. */ +export const GB_W = 160; +export const GB_H = 144; + +/** Fixed simulation step: 60 Hz, matching the UI runtime's FIXED_DT. */ +export const TICK_HZ = 60; + +// --------------------------------------------------------------------------- +// Input +// --------------------------------------------------------------------------- + +/** + * The core's abstract button set. Hosts map their physical buttons onto this + * (the PSP host maps CROSS->a, CIRCLE->b to match the console's confirm + * convention); the core, the tapes and the goldens only ever see these bits, + * which is what lets one input tape replay on every host. + */ +export const MON_BTN = { + up: 1 << 0, + down: 1 << 1, + left: 1 << 2, + right: 1 << 3, + a: 1 << 4, + b: 1 << 5, + start: 1 << 6, + select: 1 << 7, +} as const; + +// --------------------------------------------------------------------------- +// Party / bag / box limits +// --------------------------------------------------------------------------- + +export const PARTY_MAX = 6; +export const MOVES_MAX = 4; +export const BOX_COUNT = 8; +export const BOX_SIZE = 20; +export const BAG_MAX = 20; +/** Max simultaneously-loaded actors on a map (player is index 0). */ +export const ACTORS_MAX = 16; +/** Level cap — growth curves are evaluated up to this. */ +export const LEVEL_MAX = 100; +/** Stat stages clamp to +-6. */ +export const STAGE_MIN = -6; +export const STAGE_MAX = 6; + +// --------------------------------------------------------------------------- +// Enums — closed vocabularies the core and guest share +// --------------------------------------------------------------------------- + +/** Facing / movement direction. Matches the walk-sheet frame order. */ +export const DIR = { + down: 0, + up: 1, + left: 2, + right: 3, +} as const; + +/** What a cell does, derived from its bottom-left 8x8 tile. */ +export const CELL = { + wall: 0, + floor: 1, + grass: 2, + water: 3, + door: 4, + warp: 5, + ledgeDown: 6, + counter: 7, +} as const; + +/** Persistent status conditions. `none` must stay 0 (save-format default). */ +export const STATUS = { + none: 0, + sleep: 1, + poison: 2, + burn: 3, + freeze: 4, + paralysis: 5, + badPoison: 6, +} as const; + +/** + * Damage category. Gen 1 splits physical/special by the move's TYPE, not per + * move; a type record carries its category and a move may override it. + */ +export const CATEGORY = { + physical: 0, + special: 1, + status: 2, +} as const; + +/** Experience growth curves (see growth.rs for the polynomial of each). */ +export const GROWTH = { + mediumFast: 0, + slightlyFast: 1, + slightlySlow: 2, + mediumSlow: 3, + fast: 4, + slow: 5, +} as const; + +/** The overworld/battle top-level mode the core is in. */ +export const MODE = { + overworld: 0, + text: 1, + battle: 2, + menu: 3, + transition: 4, +} as const; + +/** Battle phase — what the core is waiting for. */ +export const PHASE = { + intro: 0, + chooseAction: 1, + chooseMove: 2, + resolving: 3, + message: 4, + chooseSwitch: 5, + ended: 6, +} as const; + +/** The player's battle action, as chosen through `chooseAction`. */ +export const ACTION = { + fight: 0, + bag: 1, + swap: 2, + run: 3, +} as const; + +/** How a battle finished (payload of the `battleEnded` event). */ +export const OUTCOME = { + win: 0, + loss: 1, + ran: 2, + caught: 3, + draw: 4, +} as const; + +/** Actor movement behavior for NPCs. */ +export const BEHAVIOR = { + still: 0, + wander: 1, + paceH: 2, + paceV: 3, + spin: 4, +} as const; + +// --------------------------------------------------------------------------- +// Ops — guest -> core intent. APPEND ONLY. +// --------------------------------------------------------------------------- +// +// Signatures (authoritative; the host marshals them as QuickJS C functions +// under `globalThis.mon`): +// +// -- content (boot-time upload; see MONPAK below) ------------------------ +// loadContent(blob: ArrayBuffer) -> bool cooked MONPAK, parsed core-side +// defineType(id, name, category, matchups: ArrayBuffer) -> i32 +// defineSpecies(id, blob: ArrayBuffer) -> i32 (SPECIES record layout) +// defineMove(id, blob: ArrayBuffer) -> i32 (MOVE record layout) +// defineItem(id, blob: ArrayBuffer) -> i32 +// defineMap(id, blob: ArrayBuffer) -> i32 (MAP record layout) +// defineScript(key: string, blob: ArrayBuffer) -> i32 (compiled SCRIPT) +// defineText(key: string, s: string) -> i32 +// defineTrainer(id, blob: ArrayBuffer) -> i32 +// +// -- world --------------------------------------------------------------- +// enterMap(mapId, cellX, cellY, dir) hard placement (new game/load) +// warpTo(mapId, cellX, cellY, dir, style) fade + placement +// setActor(slot, blob) spawn/update an NPC +// hideActor(slot) / showActor(slot) +// moveActor(slot, dir, cells) queued scripted walk +// faceActor(slot, dir) +// setFlag(id, value) / getFlag(id) -> i32 +// setBlock(mapId, blockX, blockY, blockId) replace_block +// showText(s: string, opts) -> i32 push a textbox; returns handle +// showChoice(s: string, opts) -> i32 yes/no or list +// closeText() +// setMode(mode) +// playMusic(id) / stopMusic() / playSfx(id) / playCry(speciesId) +// +// -- party / bag --------------------------------------------------------- +// givemon(speciesId, level, flags) -> slot | -1 +// healParty() +// giveItem(itemId, qty) -> bool / takeItem(itemId, qty) -> bool +// setPartyMove(slot, moveIdx, moveId) +// +// -- battle -------------------------------------------------------------- +// startWild(speciesId, level, flags) +// startTrainer(trainerId, flags) +// chooseAction(action) / chooseMove(idx) / chooseItem(itemId) +// chooseSwitch(slot) / advance() advance() = "A" on a message +// endBattle() +// +// -- query (cold path: menu rendering reads these, never per-frame) ------ +// view(kind) -> ArrayBuffer packed snapshot (VIEW_* below) +// partySlot(i) -> ArrayBuffer +// text(key) -> string +// +// -- system -------------------------------------------------------------- +// save() -> bool / load() -> bool / hasSave() -> bool +// seed(lo, hi) deterministic RNG seed +// viewport(w, h) logical view size +// events() -> ArrayBuffer drain the per-tick event batch +// frameStats() -> ArrayBuffer debug counters + +export const MON_OP = { + loadContent: 1, + defineType: 2, + defineSpecies: 3, + defineMove: 4, + defineItem: 5, + defineMap: 6, + defineScript: 7, + defineText: 8, + defineTrainer: 9, + + enterMap: 20, + warpTo: 21, + setActor: 22, + hideActor: 23, + showActor: 24, + moveActor: 25, + faceActor: 26, + setFlag: 27, + getFlag: 28, + setBlock: 29, + showText: 30, + showChoice: 31, + closeText: 32, + setMode: 33, + playMusic: 34, + stopMusic: 35, + playSfx: 36, + playCry: 37, + + givemon: 50, + healParty: 51, + giveItem: 52, + takeItem: 53, + setPartyMove: 54, + + startWild: 70, + startTrainer: 71, + chooseAction: 72, + chooseMove: 73, + chooseItem: 74, + chooseSwitch: 75, + advance: 76, + endBattle: 77, + + view: 90, + partySlot: 91, + text: 92, + + save: 110, + load: 111, + hasSave: 112, + seed: 113, + viewport: 114, + events: 115, + frameStats: 116, +} as const; + +// --------------------------------------------------------------------------- +// Events — core -> guest facts, drained as one batch per tick. APPEND ONLY. +// --------------------------------------------------------------------------- +// +// Wire layout: a u32 count, then `count` records of EVENT_SIZE bytes: +// u16 kind | u16 a | i32 b | i32 c | i32 d +// String payloads are interned: `d` indexes the event string table, read back +// with `mon.text("$evt:")`. Numeric-only events keep the boundary cheap. + +export const MON_EVENT = { + /** Player pressed A facing an actor. a = actor slot, b = text key id. */ + talk: 1, + /** Player pressed A facing a sign. b = text key id. */ + sign: 2, + /** Arrived on a new map. a = mapId, b = cellX, c = cellY, d = dir. */ + warped: 3, + /** Wild encounter rolled. a = speciesId, b = level. */ + encounter: 4, + /** Battle finished. a = OUTCOME, b = trainerId or -1. */ + battleEnded: 5, + /** A script ran to completion. a = script handle. */ + scriptDone: 6, + /** A textbox the guest pushed was dismissed. a = handle. */ + textDone: 7, + /** A choice box resolved. a = handle, b = chosen index. */ + choiceDone: 8, + /** The core wants the guest to open a menu. a = menu kind. */ + menuRequest: 9, + /** a = party slot, b = new level. */ + levelUp: 10, + /** a = party slot, b = from species, c = to species. */ + evolve: 11, + /** a = speciesId, b = level, c = party slot or -1 when boxed. */ + caught: 12, + /** a = side (0 = player), b = party slot. */ + faint: 13, + /** A script asked the guest to run a verb it does not implement natively. */ + scriptHook: 14, + /** Battle message queue drained; guest may render the next prompt. */ + battlePrompt: 15, +} as const; + +/** Bytes per packed event record: 2 + 2 + 4 + 4 + 4 (see the wire layout above). */ +export const EVENT_SIZE = 16; +/** Max events buffered in one tick; overflow drops the tail and sets a stat. */ +export const EVENT_CAP = 64; + +// --------------------------------------------------------------------------- +// View snapshots — `view(kind)` packed reads for menu rendering +// --------------------------------------------------------------------------- + +export const VIEW = { + world: 0, + party: 1, + battle: 2, + bag: 3, + player: 4, + dex: 5, +} as const; + +// --------------------------------------------------------------------------- +// MONPAK — the cooked content container +// --------------------------------------------------------------------------- +// +// Layout: +// 0 u32 MAGIC ('MONP' LE) +// 4 u16 VERSION +// 6 u16 section count +// 8 u32 total byte length +// 12 u32 reserved (0) +// 16 section table: `count` entries of MONPAK_ENTRY_SIZE bytes: +// u32 tag | u32 offset | u32 length | u32 count +// .. section payloads, each aligned to MONPAK_ALIGN +// +// Every offset is from the start of the blob. Sections appear in tag order. + +export const MONPAK_MAGIC = 0x504e4f4d; // 'MONP' +export const MONPAK_VERSION = 1; +export const MONPAK_HEADER_SIZE = 16; +export const MONPAK_ENTRY_SIZE = 16; +export const MONPAK_ALIGN = 16; + +/** Section tags (4CC, LE u32). */ +export const MONPAK_TAG = { + /** CLUT8 atlas pages: u16 pageCount, then per page u16 w,h + pixels. */ + atlas: 0x534c5441, // 'ATLS' + /** 256-entry u32 ABGR palette (the whole game shares one; FX rewrite it). */ + palette: 0x4c415041, // 'APAL' + /** Tilesets: block tile arrays + per-tile behavior. */ + tileset: 0x53454c54, // 'TLES' + /** Maps: block layout, warps, signs, actors, connections, encounters. */ + maps: 0x5350414d, // 'MAPS' + /** Species records. */ + species: 0x43455053, // 'SPEC' + /** Move records. */ + moves: 0x564f4d53, // 'SMOV' + /** Type records + the matchup table. */ + types: 0x50595453, // 'STYP' + /** Item records. */ + items: 0x4d455449, // 'ITEM' + /** Trainer records (party rosters + AI class + reward). */ + trainers: 0x4e525254, // 'TRRN' + /** Compiled scripts, keyed by name. */ + scripts: 0x54504353, // 'SCPT' + /** The string table (UTF-8, length-prefixed), keyed by name. */ + text: 0x54584554, // 'TEXT' + /** The font: charmap + glyph metrics into the atlas. */ + font: 0x544e4f46, // 'FONT' + /** Music + SFX: channel programs for the chip synth. */ + audio: 0x4f445541, // 'AUDO' +} as const; + +// --------------------------------------------------------------------------- +// Section payload layouts +// --------------------------------------------------------------------------- +// +// Every section payload starts with a 4-byte header — `u16 count` plus two +// bytes whose meaning is per-section (mostly reserved) — so the core can size +// its registries in one read before touching records. Variable-length sections +// (maps, trainers, scripts, text) follow the header with a u32 offset table +// relative to the payload start. + +/** Bytes of per-section header preceding every payload's records. */ +export const SECTION_HEADER_SIZE = 4; + +/** ATLS page header: u16 w, u16 h, u32 byteLen, then w*h CLUT8 pixels. */ +export const ATLAS_PAGE_HEADER_SIZE = 8; +/** APAL is exactly 256 u32 ABGR entries. */ +export const PALETTE_ENTRIES = 256; +export const PALETTE_BYTES = 1024; + +/** + * TLES per-tileset: u16 blockCount, u16 reserved, then blockCount block + * records of BLOCK_TILES^2 (=16) tile ids, then a 256-byte table mapping tile + * id -> CELL behavior. The behavior table is what makes the bottom-left-tile + * rule a single array read per collision query. + */ +export const TILESET_HEADER_SIZE = 4; +export const TILESET_BLOCK_SIZE = 16; +export const TILE_BEHAVIOR_BYTES = 256; + +/** SPEC header: u16 count, u16 learnPoolCount; learnset pairs follow records. */ +export const SPECIES_SECTION_HEADER_SIZE = 4; +/** One learnset entry: u16 level, u16 moveId. */ +export const LEARN_SIZE = 4; + +/** STYP header: u16 typeCount, u16 matchupCount. */ +export const TYPE_SECTION_HEADER_SIZE = 4; +/** TYPE record, 4 bytes: u8 category, u8 reserved, u16 nameKey. */ +export const TYPE_SIZE = 4; +/** MATCHUP record, 4 bytes: u8 attacker, u8 defender, u16 multiplier (x10). */ +export const MATCHUP_SIZE = 4; + +/** + * ITEM record, 12 bytes: + * 0 u16 id | 2 u16 nameKey | 4 u16 descKey | 6 u8 kind | 7 u8 param + * 8 u16 price | 10 u16 reserved + */ +export const ITEM_SIZE = 12; + +/** What an item does when used. */ +export const ITEM_KIND = { + none: 0, + ball: 1, + heal: 2, + status: 3, + revive: 4, + boost: 5, + key: 6, + escape: 7, + repel: 8, +} as const; + +/** + * FONT header: u16 glyphCount, u8 lineHeight, u8 page; then glyph records of + * GLYPH_SIZE bytes: u32 codepoint, u16 u, u16 v, u8 w, u8 h, u8 advance, + * u8 reserved. + */ +export const FONT_HEADER_SIZE = 4; +export const GLYPH_SIZE = 12; + +/** + * SCPT header: u16 count, u16 reserved; then `count` directory entries of + * SCRIPT_ENTRY_SIZE bytes: u16 nameKey, u16 reserved, u32 offset, u32 length. + */ +export const SCRIPT_ENTRY_SIZE = 12; + +/** + * TEXT header: u16 count, u16 reserved; then `count` entries of + * TEXT_ENTRY_SIZE bytes: u32 offset, u32 length (UTF-8, not NUL-terminated). + * A string's index IS its key id — scripts and records reference strings by + * that u16, so no hashing happens at runtime. + */ +export const TEXT_ENTRY_SIZE = 8; + +/** + * AUDO header: u16 songCount, u16 sfxCount; then a u32 offset table of + * songCount + sfxCount entries, then the channel programs (see audio.rs for + * the note-event stream layout). + */ +export const AUDIO_ENTRY_SIZE = 4; + +// --------------------------------------------------------------------------- +// Record layouts (fixed-size, LE) — the cooker writes these, the core reads them +// --------------------------------------------------------------------------- + +/** + * SPECIES, 32 bytes: + * 0 u16 id 2 u8 baseHp 3 u8 baseAtk + * 4 u8 baseDef 5 u8 baseSpd 6 u8 baseSpc + * 7 u8 type1 8 u8 type2 9 u8 catchRate + * 10 u16 baseExp 12 u8 growth 13 u8 frontTile (atlas slot) + * 14 u8 backTile 15 u8 iconTile + * 16 u16 nameKey 18 u16 dexKey + * 20 u8 learnCount 21 u8 evolveKind 22 u16 evolveParam + * 24 u16 evolveInto 26 u16 learnOffset (index into the learnset pool) + * 28 u32 reserved + */ +export const SPECIES_SIZE = 32; + +/** + * MOVE, 16 bytes: + * 0 u16 id 2 u8 type 3 u8 power + * 4 u8 accuracy 5 u8 pp 6 u8 category + * 7 u8 effect 8 u8 effectChance 9 u8 flags (bit0 highCrit) + * 10 u16 nameKey 12 u16 descKey 14 u16 animId + */ +export const MOVE_SIZE = 16; + +/** Move `flags` bits. */ +export const MOVE_FLAG_HIGH_CRIT = 1 << 0; +export const MOVE_FLAG_MULTI_HIT = 1 << 1; +export const MOVE_FLAG_CHARGE = 1 << 2; +export const MOVE_FLAG_RECHARGE = 1 << 3; +/** + * Moves first regardless of speed. One bit rather than a signed priority + * field because the record has no spare byte and this covers the whole + * "quick attack" class; a future spec bump can widen it. + */ +export const MOVE_FLAG_PRIORITY = 1 << 4; + +/** + * Move effects the core implements natively. Anything else is a `scriptHook` + * event for the guest. Append-only. + */ +export const EFFECT = { + none: 0, + burnChance: 1, + freezeChance: 2, + paralyzeChance: 3, + poisonChance: 4, + sleep: 5, + confuse: 6, + flinchChance: 7, + atkDown: 8, + defDown: 9, + spdDown: 10, + spcDown: 11, + accDown: 12, + atkUp: 13, + defUp: 14, + spdUp: 15, + spcUp: 16, + drain: 17, + recoil: 18, + multiHit: 19, + twoHit: 20, + ohko: 21, + highCrit: 22, + charge: 23, + hyperBeam: 24, + reflect: 25, + lightScreen: 26, + haze: 27, + heal: 28, + rest: 29, + explode: 30, + fixedDamage: 31, + levelDamage: 32, + superFang: 33, + swift: 34, + trap: 35, + payDay: 36, + mist: 37, + focusEnergy: 38, + substitute: 39, + transform: 40, + conversion: 41, + metronome: 42, + mirrorMove: 43, + disable: 44, + leechSeed: 45, + dreamEater: 46, +} as const; + +/** + * MAP header, 32 bytes, followed by the block array and the variable sections: + * 0 u16 id 2 u8 width (blocks) 3 u8 height (blocks) + * 4 u8 tileset 5 u8 borderBlock 6 u8 flags + * 7 u8 encounterRate + * 8 u16 nameKey 10 u16 musicId + * 12 u8 warpCount 13 u8 signCount 14 u8 actorCount 15 u8 slotCount + * 16 i16 connNorth 18 i16 connSouth 20 i16 connWest 22 i16 connEast + * 24 i16 connNorthOff 26 i16 connSouthOff 28 i16 connWestOff 30 i16 connEastOff + */ +export const MAP_HEADER_SIZE = 32; +/** Map `flags` bits. */ +export const MAP_FLAG_INDOOR = 1 << 0; +export const MAP_FLAG_DARK = 1 << 1; +export const MAP_FLAG_NO_ESCAPE = 1 << 2; + +/** WARP, 8 bytes: u8 x, u8 y, u16 destMap, u8 destWarp, u8 dir, u16 reserved. */ +export const WARP_SIZE = 8; +/** SIGN, 4 bytes: u8 x, u8 y, u16 textKey. */ +export const SIGN_SIZE = 4; +/** + * ACTOR, 12 bytes: + * 0 u8 x | 1 u8 y | 2 u8 dir | 3 u8 behavior + * 4 u8 sprite | 5 u8 flags | 6 u16 textKey + * 8 i16 trainerId | 10 u16 flagGate (hidden while this flag is set; 0xffff = none) + */ +export const ACTOR_SIZE = 12; +/** ENCOUNTER SLOT, 4 bytes: u16 species, u8 level, u8 reserved. */ +export const SLOT_SIZE = 4; +/** Encounter slots per map (the classic bucket count). */ +export const SLOT_COUNT = 10; +/** + * Cumulative slot thresholds out of 256. rand(0..255) picks the first bucket + * it falls under. Ported from the upstream engine's wild-encounter buckets. + */ +export const ENCOUNTER_BUCKETS = [51, 102, 141, 166, 191, 212, 233, 243, 253, 256]; + +/** + * TRAINER header, 8 bytes + roster: + * 0 u16 id | 2 u16 nameKey | 4 u8 aiClass | 5 u8 partyCount + * 6 u16 rewardBase + * followed by `partyCount` TRAINER_MON records of 12 bytes: + * u16 species | u8 level | u8 flags | u16 move0..move3 (8 bytes) + */ +export const TRAINER_HEADER_SIZE = 8; +export const TRAINER_MON_SIZE = 12; +export const TRAINER_PARTY_MAX = 6; + +// --------------------------------------------------------------------------- +// Script VM — the compiled command list +// --------------------------------------------------------------------------- +// +// A compiled script is a header + an instruction stream: +// 0 u16 version | 2 u16 opCount | 4 u16 labelCount | 6 u16 reserved +// then `labelCount` u32 label offsets, then the stream. +// Each instruction: u8 verb | u8 argCount | then `argCount` i32 args. +// String arguments are text-table key ids (u16 widened to i32). +// +// The verb set is the upstream `src/script/Commands.lua` vocabulary, trimmed to +// what the core implements natively; unknown verbs raise `scriptHook`. + +export const SCRIPT_VERSION = 1; +export const SCRIPT_HEADER_SIZE = 8; + +export const VERB = { + end: 0, + showText: 1, + ask: 2, + jump: 3, + jumpIfTrue: 4, + jumpIfFalse: 5, + setFlag: 6, + clearFlag: 7, + checkFlag: 8, + checkItem: 9, + giveItem: 10, + takeItem: 11, + startBattle: 12, + warp: 13, + wait: 14, + movePlayer: 15, + moveNpc: 16, + faceNpc: 17, + facePlayer: 18, + showObject: 19, + hideObject: 20, + playSound: 21, + playCry: 22, + playMusic: 23, + stopMusic: 24, + healParty: 25, + givemon: 26, + giveMoney: 27, + checkBattleResult: 28, + trainerBattle: 29, + openMart: 30, + replaceBlock: 31, + fade: 32, + panCamera: 33, + emote: 34, + label: 35, + hook: 36, + setField: 37, + choice: 38, + waitFlag: 39, + textOpts: 40, +} as const; + +// --------------------------------------------------------------------------- +// Draw list — the backend-independent frame output (mirrors DrawList in core) +// --------------------------------------------------------------------------- +// +// The core emits one MonDrawList per frame in LOGICAL view pixels +// (VIEW_W x VIEW_H); the host scales it. Two arrays, drawn in order: +// quads: textured, from the CLUT8 atlas pages +// rects: solid ABGR fills (text boxes, fades, HP bars) +// +// QUAD, 16 bytes: i16 x | i16 y | u16 u | u16 v | u8 w | u8 h | u8 page | +// u8 flags | u32 tint +// RECT, 12 bytes: i16 x | i16 y | u16 w | u16 h | u32 color + +export const QUAD_SIZE = 16; +export const RECT_SIZE = 12; +export const QUAD_FLAG_FLIP_X = 1 << 0; +export const QUAD_FLAG_FLIP_Y = 1 << 1; +/** Tint is modulated per-vertex; 0xffffffff is untinted. */ +export const TINT_NONE = 0xffffffff; +/** Atlas page edge, in pixels (CLUT8, so one page is PAGE_PX^2 bytes). */ +export const PAGE_PX = 256; +export const PAGE_MAX = 8; + +// --------------------------------------------------------------------------- +// Save format +// --------------------------------------------------------------------------- +// +// 0 u32 MAGIC ('MSAV') | 4 u16 VERSION | 6 u16 flags +// 8 u32 byte length | 12 u32 FNV-1a checksum of everything after byte 16 +// 16 payload: player, party, boxes, bag, flags, world position, RNG state +// +// The checksum is the same FNV-1a the pak uses, so hosts share one helper. + +export const SAVE_MAGIC = 0x5641534d; // 'MSAV' +export const SAVE_VERSION = 1; +export const SAVE_HEADER_SIZE = 16; +/** FNV-1a 32-bit, the same constants the .pak container uses. */ +export const FNV1A_OFFSET_BASIS = 0x811c9dc5; +export const FNV1A_PRIME = 0x01000193; +/** Event flags addressable by scripts. */ +export const FLAG_COUNT = 512; + +// --------------------------------------------------------------------------- +// Battle tuning constants (the ruleset the core defaults to) +// --------------------------------------------------------------------------- + +/** Damage randomization range: d = d * rand(217..255) / 255. */ +export const RAND_MIN = 217; +export const RAND_MAX = 255; +/** Stat-stage multipliers x100, indexed stage + 6 (so index 6 = stage 0). */ +export const STAGE_MULT = [25, 28, 33, 40, 50, 66, 100, 150, 200, 250, 300, 350, 400]; +/** Stats clamp to this after a stage multiplication. */ +export const STAT_MAX = 999; +/** Damage before the random factor clamps here, then +2. */ +export const DAMAGE_CLAMP = 997; +/** Both stats are quartered when either exceeds this (the byte-overflow rule). */ +export const STAT_SCALE_LIMIT = 255; +/** STAB numerator/denominator (x1.5). */ +export const STAB_NUM = 3; +export const STAB_DEN = 2; +/** Type multipliers are x10 fixed point. */ +export const TYPE_SCALE = 10; + +/** Ball modifiers for the catch algorithm, indexed by item ball tier. */ +export const BALL_RATE = [255, 200, 150, 100]; +/** Status bonus to the catch roll. */ +export const CATCH_STATUS_BONUS = { none: 0, sleepFreeze: 25, other: 12 } as const; diff --git a/docs/MON.md b/docs/MON.md new file mode 100644 index 00000000..051550d3 --- /dev/null +++ b/docs/MON.md @@ -0,0 +1,226 @@ +# Pocket Mon — the creature-collection RPG runtime + +*A specialized PocketJS runtime for grid-world, turn-battle monster RPGs — +the fourth instance of the [RUNTIMES.md](RUNTIMES.md) pattern, after the 2D UI +runtime, the widget runtime and OpenStrike.* + +This runtime is a **clean-room port of the architecture** of +[`bryanthaboi/pokemon-gen1-recomp-project`](https://github.com/bryanthaboi/pokemon-gen1-recomp-project) +(Gen1Recomp) — a 44 kLOC Lua/LÖVE2D hand-written recreation of a Gen 1 +creature RPG. What is ported is that project's *engine decomposition and +its documented Gen-1 behavioral rules*. What is **not** ported, and never +will be, is its content path. + +## 1. The clean-room boundary + +Gen1Recomp is a *shell*: it ships no game content and reconstructs everything +— graphics, maps, species tables, text, audio programs — by decoding a +player-supplied Game Boy ROM at first boot (`src/import/RomImporter.lua`, +`src/import/RomExtractor.lua`). Its whole first-boot pipeline exists to turn +copyrighted ROM bytes into a private cache. + +Pocket Mon deliberately has **no counterpart to that layer**. There is no ROM +reader, no SHA-1 gate, no importer, no extracted cache, and no code path that +can consume a ROM. Instead: + +| Gen1Recomp | Pocket Mon | +| --- | --- | +| `src/import/*` — ROM → private cache | *(absent)* `apps/mon/content/*.ts` — hand-authored TS | +| extracted species/moves/maps | original species, moves, maps, dialogue | +| extracted 2bpp tile graphics | original art, procedurally generated at cook time | +| extracted GB channel programs | original chiptune scores, authored as note data | + +The result is **SPARKWOOD**, an original creature-collecting adventure: our +own world, our own creatures, our own art and music. It is playable from a +clean checkout with nothing else supplied. + +What legitimately crosses over is **mechanics** — the damage formula, the stat +formula, growth curves, the encounter roll, the catch algorithm, the +bottom-left-tile collision rule. Game mechanics are not copyrightable, these +are exhaustively documented in public references, and Gen1Recomp itself cites +its provenance per formula. Every ported rule keeps that citation in the Rust +source so the lineage stays auditable. + +No trademarked names, characters, sprites, maps, music, or text are used. + +## 2. The ontology + +Per RUNTIMES.md, `Runtime = ⟨ Cores, Surfaces, Guest ⟩`: + +``` +pocketmon-psp (EBOOT) engine/pocketmon/crates/pocketmon-psp + = pocketmon-core the RPG core: world, battle, script VM, save, + | text, the scene builder and the `mon` surface + + pocketmon-gu the GE backend for the core's draw list + + an arena allocator one static block; see the crate's arena.rs + +pocketmon-sim (headless) engine/pocketmon/crates/pocketmon-sim + = pocketmon-core + + a software rasterizer the reference the PSP backend is checked against + +SPARKWOOD (the game) apps/mon + = content in TypeScript species, moves, maps, scripts, trainers, text + + procedural art tiles, walk sheets, portraits, a 5x7 font + -> one cooked MONPAK embedded in the EBOOT at build time +``` + +**What is wired today.** The core is complete and the EBOOT runs it: the +console boots, parses the embedded pak, and plays the game. The `mon` surface +is specified in the contract and *implemented* as `Game::op` — one dispatcher +covering every op, exercised by `cargo test` — and `apps/mon/sdk.ts` is the +guest-side algebra over it. + +**What is not wired yet.** No QuickJS realm is mounted in the EBOOT, so +`Game::op` currently has no JS caller on the console: the shipped game's +behaviour comes from its cooked content plus the native script VM. Mounting +`pocket-mod` (or the raw QuickJS embedding `pocketjs-psp` already carries) and +binding the dispatcher is a thin layer over a surface that is already written +and tested — but it is not done, and this document will not pretend otherwise. +The same goes for mounting the `ui` surface for menus: the core draws its own +battle UI and dialogue today. + +The base game is meant to be the first mod (RUNTIMES.md discipline #5), and +the content path already honours it — every species, move, map, script and +trainer is data, and the core ships none of it. What is missing is only the +realm that would let that data arrive from a running JS program rather than a +cooked file. + +### Where the upstream Lua modules land + +| Gen1Recomp | Layer | Pocket Mon | +| --- | --- | --- | +| `world/Map`, `Collision`, `Warp` | **core** | `world/map.rs` — cell queries, the bottom-left-tile rule, connections | +| `world/Player`, `NPC` | **core** | `world/actor.rs` — grid movement, walk cycle, scripted walks | +| `world/OverworldController`, `Encounter` | **core** | `world/overworld.rs` — input, interaction, warps, the encounter roll | +| `render/TileRenderer`, `SpriteRenderer`, `Camera` | **core** | `scene.rs` → `MonDrawList` | +| `render/Font`, `TextBox` | **core** | `text.rs` — charmap, wrapping, typewriter, `\n` `\v` `\f` | +| `battle/Damage`, `TypeChart` | **core** | `battle/damage.rs` + the matchup table in `content.rs` | +| `battle/BattleState`, `TurnOrder`, `Status` | **core** | `battle/mod.rs` | +| `battle/MoveEffects` | **core** | `battle/effects.rs` — 30 effects natively, the rest as guest hooks | +| `battle/Catching` | **core** | `battle/catching.rs` | +| `battle/TrainerAI` | **core** | `battle/ai.rs` — the AI class is a smartness dial | +| `battle/Experience` | **core** | `mon/growth.rs` | +| `pokemon/Stats`, `Growth`, `Party`, `Boxes` | **core** | `mon/stats.rs`, `mon/growth.rs`, `mon/mod.rs` | +| `core/SaveData`, `script/Flags` | **core** | `save.rs` — flat binary, checksummed, versioned | +| `script/ScriptRunner`, `Commands` | **core** | `script.rs` — a resumable 41-verb VM | +| `core/FixedStep`, `Input`, `StateStack` | **host** | the one-turn-per-tick frame loop (Law 3) | +| `ui/*` (~40 menu modules) | **core** | `scene.rs` draws the battle UI and dialogue from core state | +| `data/scripts/*.lua` | **guest** | `apps/mon/content/game.ts` `SCRIPTS`, compiled by the cooker | +| `mods/*` | **guest** | the surface *is* the mod API (`apps/mon/sdk.ts`) | +| `import/*` | — | *(deliberately absent — see §1)* | +| `core/ChipSynth`, `ChipAudio`, `Music` | — | **not implemented.** The core states what should be sounding (`Game::music`, `sfx`, `cry`) and no host acts on it yet | +| `link/*` (UDP link play) | — | out of scope for v1 | + +### Why the split falls where it does + +The [QuickJS PSP perf wall](DETERMINISM.md) is the forcing function: measured +at ~1.7 µs/op on a 333 MHz PSP, a guest gets ~8 k boundary ops per frame. So +everything per-entity and per-frame — tile emission, sprite animation, +collision, camera, the whole battle turn — is Rust. The guest runs once per +tick to answer events and drive menus, and its content upload happens once at +boot. Overworld frames cost the guest **zero** ops. + +## 3. The `mon` surface + +Pinned in `contracts/spec/mon-spec.ts`, codegen'd to +`engine/pocketmon/crates/pocketmon-core/src/spec.rs`, byte-compared in +`tests/mon-contract.test.ts`. Append-only: codes are never renumbered. + +**Ops** (guest → core) fall in five groups: + +- **content** — `loadContent` takes a cooked MONPAK. The per-record `defineX` + ops are reserved in the contract for the mod path and are not implemented + yet; a mod today ships a second pak and merges it. +- **world** — `enterMap`, `warpTo`, `moveActor`, `faceActor`, `showActor`, + `hideActor`, `setFlag`, `getFlag`, `setBlock`, `showText`, `showChoice`, + `closeText`, `setMode`, `playMusic`, `stopMusic`, `playSfx`, `playCry`. +- **party** — `givemon`, `healParty`, `giveItem`, `takeItem`, `setPartyMove`. +- **battle** — `startWild`, `startTrainer`, `chooseAction`, `chooseMove`, + `chooseItem`, `chooseSwitch`, `advance`, `endBattle`. +- **query** — `view(kind)` returns a packed snapshot (world, party, battle, + bag, player, dex); `partySlot(i)` and `text(key)` for the rest. Cold path + only: nothing here is meant to be called per frame. +- **system** — `save`, `load`, `hasSave`, `seed`, `viewport`, `events`, + `frameStats`. + +All of them route through one dispatcher, `Game::op(code, args)` in +`surface.rs`, so a host binding is a marshalling shim over a single function +rather than fifty trampolines. An unknown code is a no-op, not a panic: codes +are append-only and a guest built against a newer spec has to degrade. + +**Events** (core → guest) are drained once per tick as a batch: +`talk`, `sign`, `warped`, `encounter`, `battleEnded`, `scriptDone`, +`textDone`, `menuRequest`, `levelUp`, `evolve`, `caught`, `faint`. + +The frame contract is Law 3 exactly: the host calls `tick(buttons)` once per +fixed 60 Hz tick, then `render()`. Frame content is a pure function of tick +index + inputs + seed, which is what makes both the sim hashes and the PSP +frame goldens byte-exact. + +## 4. Coordinates and the viewport + +Inherited wholesale from the upstream engine, because they are what make the +collision rules legible: + +- **block** — 32×32 px, the unit of a map's layout array +- **cell** — 16×16 px, the walk grid; every actor, warp and sign coordinate +- **tile** — 8×8 px, the graphics unit. A cell is 2×2 tiles, a block 4×4. + +A cell's behavior — passable, grass, door, warp, water — is decided by its +**bottom-left 8×8 tile**, matching the original engine's "tile at the +sprite's feet" check. This one rule is why the upstream project's collision +matches; it is ported verbatim. + +**Viewport.** The GB screen is 160×144. The PSP is 480×272, which is 3.0× the +width but only 1.889× the height — no integer scale fits. Rather than +letterbox to 1× (wasting three-quarters of the panel) or blur to a fractional +scale, Pocket Mon renders at **2× into a 240×136 logical view**: sharp doubled +pixels, the full panel used, and 30×17 tiles visible instead of the GB's +20×18. The core takes the logical view size as a parameter, so a 1× host (or +a future 160×144 e-ink host) is a config change, not a fork. + +## 5. The three laws + +- **Law 1 — state in cores, mirrors in guests.** The party, bag, flags, world + and battle live in Rust. A guest holds a mirror it refreshes from events and + from the packed `view()` snapshots; nothing reads the boundary per frame. +- **Law 2 — intent as ops, facts as events.** No callbacks from native + mid-tick, no shared memory. A script's `show_text` is a core→guest `talk` + event; the guest answers with `showText` ops. +- **Law 3 — one guest turn per host tick.** The script VM is a resumable + state machine, not a coroutine, precisely so a blocking `show_text` costs + no thread and stays replayable. + +## 6. Verification + +A runtime without a headless story is not done (discipline #4): + +- **`cargo test`** in `engine/pocketmon/crates/pocketmon-core` — 245 tests over + the rules: damage across the crit / STAB / dual-type / stat-scaling matrix, + the crit and accuracy roll distributions, stat and growth curves, the + encounter roll's bucket boundaries, catch rates, turn order including speed + ties, collision and the bottom-left-tile rule, seam crossing, the script + VM's blocking verbs, save round-trip and corruption rejection, and the whole + `mon` surface. +- **`bun test tests/mon-contract.test.ts tests/mon-content.test.ts`** — the + spec drift guard, and 29 content-integrity checks: every learnset move + exists, every warp lands on a cell that actually has a door under it, every + connection is declared from both sides with mirrored offsets, the font + covers every character the game writes, cooking twice is byte-identical, and + the runtime has no code path that reads a ROM. +- **`bun tools/mon.ts check`** — the deterministic playthrough: out of the + house, across the village, take a starter from the professor, north to Route + One, and win two wild battles, asserted against recorded frame hashes. + `bun tools/mon.ts shots` writes a PNG per checkpoint to look at. +- **`bun tests/e2e/mon-ppsspp.ts`** — the same journey on the console: a + capture EBOOT with a baked input tape under PPSSPPHeadless's software + renderer, checked for liveness (every frame of the window presented) and + byte-compared against committed PNG goldens. +- Real hardware, over PSPLINK — **not yet run**. + +## 7. Out of scope for v1 + +Link play (upstream's UDP `src/link/*`), the save editor, Discord presence, +the mod manager UI, and the slot-machine minigame. None of them are load +bearing for the runtime thesis; all of them are expressible through the +surface later, by a guest, without touching the core. diff --git a/docs/RUNTIMES.md b/docs/RUNTIMES.md index fbbfb380..f69e0b32 100644 --- a/docs/RUNTIMES.md +++ b/docs/RUNTIMES.md @@ -154,6 +154,8 @@ The grammar is implemented once, as infrastructure every runtime reuses: | `pocket3d-gu` | The 3D substrate, PSP edition: renders cooked worlds through the GE (sceGu) with PVS culling, CLUT8 textures, and dynamic meshes. | | `pocketjs-psp` (lib) | Guest hosting + `ui` surface, PSP edition: the arena allocator, the QuickJS embedding, the DrawList GE backend (with an overlay mode for 3D compositing), pak feeding, and the DevTools mailbox — everything the 2D EBOOT proved, linkable by game EBOOTs. | | `pocket3d-vita` | The 3D substrate, Vita edition: CPU projection and six-plane clipping into vita2d/GXM at 960x544, painter-sorted so a PocketJS HUD can share the same scene. | +| `pocketmon-core` | The creature-RPG core (docs/MON.md): grid world, turn battle, script VM, save, and the `mon` surface's op dispatcher. no_std + alloc, zero dependencies, zero floating point. | +| `pocketmon-gu` | That core's draw list, rendered through the GE. | | `pocketjs-vita` (lib) | Guest hosting + `ui` surface, Vita edition: QuickJS, density-2 pak/font resources, controller/dual-analog input, logical-coordinate front-panel contacts and a native-density 960x544 vita2d backend over the portable 480x272 logical layout. | A specialized runtime is then a thin composition. OpenStrike is: @@ -174,6 +176,13 @@ The PSP UI runtime, in the same notation: a QuickJS guest + the `ui` surface + the sceGu backend. It was the first instance of the pattern all along; the architecture names it and makes it repeatable. +Pocket Mon (docs/MON.md) is the newest instance, and an honest illustration of +how much of the pattern you get before the guest arrives: its core, its +surface and its `.monpak` asset format are all in place and its EBOOT runs on +a PSP, while the QuickJS realm that would let a JS program drive that surface +is still to come. The vocabulary was worth writing first regardless — it is +what made the core testable without a console. + And the composition is now proven portable: **OpenStrike runs on a real PSP** as `openstrike-core` (the same FPS simulation) + `pocket3d-gu` over a cooked map + the `pocketjs-psp` host library + the same `strike` surface mounted diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index bf40b7b2..5ccd2553 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -13,6 +13,7 @@ pocketjs/ │ ├─ wasm/ core compiled to wasm32 for web/sim hosts (standalone crate) │ ├─ symbian/ no_std core static library for the GCCE/Symbian host (standalone crate) │ ├─ pocket3d/ the 3D core family (bsp, cook, gu, vita) + desktop examples +│ ├─ pocketmon/ the Pocket Mon RPG runtime (core, gu, psp EBOOT, sim) — docs/MON.md │ ├─ crates/ non-3D engine crates: pocket-mod, pocket-ui-surface, pocket-ui-wgpu, pocket-vrm, pocket-widget │ └─ Cargo.toml the desktop workspace root (core/, wasm/, symbian/, and │ console-toolchain crates are deliberately excluded and @@ -50,7 +51,9 @@ pocketjs/ New things go where the axis says — never invent a top-level directory: - **A new Rust simulation core** → `engine/` (workspace member if it builds on - desktop; excluded standalone crate if it needs a console toolchain). + desktop; excluded standalone crate if it needs a console toolchain). A whole + *runtime* family gets one directory there and keeps its crates inside it — + `engine/pocket3d/`, `engine/pocketmon/` — rather than scattering them. - **A new platform embedding** (ESP32, 3DS, …) → `hosts//`. - **A new AOT backend** (Vapor gains a console) → `vapor/runtime//`; the vapor compiler grows a target entry, the top level does not change. @@ -67,6 +70,7 @@ New things go where the axis says — never invent a top-level directory: change; the `exports`/`files` maps in package.json absorb internal moves. - **Cargo stays non-workspace where toolchains demand it**: `engine/core`, `engine/wasm`, `engine/symbian`, `engine/backends/esp32p4-ppa`, `hosts/psp`, - `hosts/vita`, `hosts/pocketbook`, and the gu/vita 3D crates each stand alone - with their own lockfiles. `engine/Cargo.toml` is the one desktop workspace. + `hosts/vita`, `hosts/pocketbook`, the gu/vita 3D crates and every + `engine/pocketmon` crate each stand alone with their own lockfiles. + `engine/Cargo.toml` is the one desktop workspace. - **Moves are `git mv`** — history stays traceable. diff --git a/engine/Cargo.toml b/engine/Cargo.toml index a3f7ddbf..2677df89 100644 --- a/engine/Cargo.toml +++ b/engine/Cargo.toml @@ -7,6 +7,9 @@ # lone standalone crates (see their Cargo.toml) # pocket3d/crates/pocket3d-gu, gu-demo — PSP-only (cargo-psp toolchain) # pocket3d/crates/pocket3d-vita — Vita-only (vitasdk toolchain) +# pocketmon/crates/* — the Pocket Mon runtime: the core is +# consumed by a lone-bin PSP EBOOT, so +# it stands alone like core/ (docs/MON.md) [workspace] resolver = "2" members = [ @@ -29,6 +32,10 @@ exclude = [ "pocket3d/crates/pocket3d-gu", "pocket3d/crates/gu-demo", "pocket3d/crates/pocket3d-vita", + "pocketmon/crates/pocketmon-core", + "pocketmon/crates/pocketmon-gu", + "pocketmon/crates/pocketmon-psp", + "pocketmon/crates/pocketmon-sim", ] [workspace.package] diff --git a/engine/pocketmon/crates/pocketmon-core/Cargo.lock b/engine/pocketmon/crates/pocketmon-core/Cargo.lock new file mode 100644 index 00000000..a4fb38f8 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/Cargo.lock @@ -0,0 +1,10 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "pocketmon-core" +version = "0.1.0" +dependencies = [ + "pocketmon-core", +] diff --git a/engine/pocketmon/crates/pocketmon-core/Cargo.toml b/engine/pocketmon/crates/pocketmon-core/Cargo.toml new file mode 100644 index 00000000..8570a250 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/Cargo.toml @@ -0,0 +1,30 @@ +# pocketmon-core — the Pocket Mon RPG core: world, entities, script VM, battle +# engine, save and the backend-independent draw list. #![no_std] + alloc by +# default so the same crate builds for mipsel-sony-psp and the host. +# See ../../../../docs/MON.md. +# +# NOTE: deliberately NOT part of any cargo workspace — cargo-psp needs the PSP +# EBOOT to be a lone-bin standalone crate, so each crate stands alone (the same +# rule engine/core follows). +# +# Zero dependencies, and zero floating point: every rule in here is integer +# math, which is what makes the byte-exact goldens and the deterministic +# replays possible on hosts with different FPUs. + +[package] +name = "pocketmon-core" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[dependencies] + +[dev-dependencies] +# Self-dependency with `std` so `cargo test` builds the harness against the +# std-enabled crate while normal builds stay no_std + alloc. +pocketmon-core = { path = ".", features = ["std"] } + +[features] +default = [] +# Host-test convenience: links std instead of no_std + alloc. +std = [] diff --git a/engine/pocketmon/crates/pocketmon-core/src/battle/ai.rs b/engine/pocketmon/crates/pocketmon-core/src/battle/ai.rs new file mode 100644 index 00000000..4d61896a --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/battle/ai.rs @@ -0,0 +1,340 @@ +//! Opponent move selection. +//! +//! Ported from upstream `src/battle/TrainerAI.lua`. The AI class on a trainer +//! record is a *smartness dial*, not a personality: class 0 (every wild +//! creature) picks uniformly at random, and each step up makes the opponent +//! more likely to take the highest-scoring option instead of a random one. +//! That keeps early trainers beatable and late ones sharp with one number in +//! the content tables and no per-trainer code. + +use crate::content::Content; +use crate::mon::stats::stat; +use crate::rng::Rng; +use crate::spec; + +use super::{Battle, Battler}; + +/// Move slots that can actually be used this turn. +fn usable(b: &Battler) -> impl Iterator + '_ { + (0..spec::MOVES_MAX).filter(move |&i| { + b.mon + .moves + .get(i) + .map(|s| !s.empty() && s.pp > 0) + .unwrap_or(false) + }) +} + +/// Score one move against the current matchup. Higher is better. +fn score(content: &Content, attacker: &Battler, defender: &Battler, idx: usize) -> i32 { + let Some(slot) = attacker.mon.moves.get(idx) else { + return i32::MIN; + }; + let Some(mv) = content.move_of(slot.id) else { + return i32::MIN; + }; + + if mv.power == 0 || mv.category == spec::category::STATUS { + // Status moves are worth something early and nothing once the target + // already has what they would inflict. + let redundant = match mv.effect { + spec::effect::SLEEP + | spec::effect::BURN_CHANCE + | spec::effect::PARALYZE_CHANCE + | spec::effect::POISON_CHANCE + | spec::effect::FREEZE_CHANCE => defender.mon.status != spec::status::NONE, + spec::effect::CONFUSE => defender.confused > 0, + spec::effect::LEECH_SEED => defender.seeded, + spec::effect::REFLECT => attacker.reflect, + spec::effect::LIGHT_SCREEN => attacker.light_screen, + spec::effect::FOCUS_ENERGY => attacker.focus_energy, + spec::effect::HEAL | spec::effect::REST => attacker.mon.hp == attacker.mon.max_hp, + spec::effect::ATK_UP => attacker.stages.get(stat::ATTACK) >= spec::STAGE_MAX, + spec::effect::DEF_UP => attacker.stages.get(stat::DEFENSE) >= spec::STAGE_MAX, + _ => false, + }; + if redundant { + return 0; + } + // Healing is worth more the more damage there is to undo. + if matches!(mv.effect, spec::effect::HEAL | spec::effect::REST) { + let missing = attacker.mon.max_hp.saturating_sub(attacker.mon.hp) as i32; + return 20 + missing * 100 / attacker.mon.max_hp.max(1) as i32; + } + return 30; + } + + // Damaging moves score on power x effectiveness x accuracy, which is a + // decent stand-in for expected damage without running the whole formula + // four times a turn on a 333 MHz CPU. + let eff = content.effectiveness(mv.kind, defender.types) as i32; + if eff == 0 { + return 0; + } + let stab = if mv.kind == attacker.types.0 || mv.kind == attacker.types.1 { + 15 + } else { + 10 + }; + let mut s = mv.power as i32 * eff * stab * mv.accuracy.max(1) as i32 / 1000; + // A move that would finish the target is worth taking now. + if eff > spec::TYPE_SCALE as i32 && defender.mon.hp * 3 <= defender.mon.max_hp { + s += 50; + } + s.max(1) +} + +/// Pick the opponent's move slot for this turn, or None when it has nothing +/// usable left. +pub fn choose(content: &Content, battle: &Battle, rng: &mut Rng) -> Option { + let attacker = &battle.foe; + let defender = &battle.player; + + // A charging or recharging creature is locked in; the slot does not matter + // but it must be a real one so `execute` can find the move. + let options: heapless_vec::Vec8 = { + let mut v = heapless_vec::Vec8::new(); + for i in usable(attacker) { + v.push(i); + } + v + }; + if options.is_empty() { + return None; + } + + // Class 0 (wild, and the greenest trainers) is pure chance. + let class = battle.foe_ai; + if class == 0 { + let pick = rng.range(0, options.len() as u32 - 1) as usize; + return options.get(pick); + } + + // Higher classes take the best option more often. Class 1 is right about + // half the time; by class 4 it is nearly always optimal. + let smart_chance = (class as u32 * 20).min(95); + if !rng.percent(smart_chance) { + let pick = rng.range(0, options.len() as u32 - 1) as usize; + return options.get(pick); + } + + let mut best = options.get(0)?; + let mut best_score = i32::MIN; + for i in 0..options.len() { + let idx = options.get(i)?; + let s = score(content, attacker, defender, idx); + if s > best_score { + best_score = s; + best = idx; + } + } + Some(best) +} + +/// A four-element inline vector — the move list never exceeds `MOVES_MAX`, and +/// allocating for it every turn would be pure waste on the PSP. +mod heapless_vec { + #[derive(Default)] + pub struct Vec8 { + items: [usize; 8], + len: usize, + } + + impl Vec8 { + pub fn new() -> Self { + Vec8 { items: [0; 8], len: 0 } + } + + pub fn push(&mut self, v: usize) { + if self.len < self.items.len() { + self.items[self.len] = v; + self.len += 1; + } + } + + pub fn len(&self) -> usize { + self.len + } + + pub fn is_empty(&self) -> bool { + self.len == 0 + } + + pub fn get(&self, i: usize) -> Option { + if i < self.len { + Some(self.items[i]) + } else { + None + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::{Matchup, Move, Species, TypeDef}; + use crate::mon::{Dvs, MonInstance}; + + const NORMAL: u8 = 0; + const FIRE: u8 = 1; + const GRASS: u8 = 2; + + fn content() -> Content { + let mut c = Content::new(); + c.types = alloc::vec![ + TypeDef { category: spec::category::PHYSICAL, name_key: 0 }, + TypeDef { category: spec::category::SPECIAL, name_key: 0 }, + TypeDef { category: spec::category::SPECIAL, name_key: 0 }, + ]; + c.matchups = alloc::vec![Matchup { attacker: FIRE, defender: GRASS, multiplier: 20 }]; + for (id, t) in [(1u16, NORMAL), (2, GRASS)] { + c.species.insert( + id, + Species { + id, + base_hp: 60, + base_atk: 60, + base_def: 60, + base_spd: 60, + base_spc: 60, + type1: t, + type2: t, + ..Default::default() + }, + ); + } + // 10 = weak normal, 11 = strong fire (super effective vs GRASS), + // 12 = a status move. + c.moves.insert( + 10, + Move { id: 10, kind: NORMAL, power: 40, accuracy: 100, pp: 30, ..Default::default() }, + ); + c.moves.insert( + 11, + Move { id: 11, kind: FIRE, power: 90, accuracy: 100, pp: 15, ..Default::default() }, + ); + c.moves.insert( + 12, + Move { + id: 12, + kind: NORMAL, + power: 0, + accuracy: 100, + pp: 20, + category: spec::category::STATUS, + effect: spec::effect::SLEEP, + ..Default::default() + }, + ); + c + } + + fn battle(c: &Content, foe_moves: &[u16], ai_class: u8) -> Battle { + let player = MonInstance::with_moves(c, 2, 20, &[10, 0, 0, 0], Dvs::default()).unwrap(); + let foe = MonInstance::with_moves(c, 1, 20, foe_moves, Dvs::default()).unwrap(); + let mut party = crate::mon::Party::default(); + party.add(player); + let mut b = Battle::wild(c, party, 0, foe).expect("battle starts"); + b.foe_ai = ai_class; + b + } + + #[test] + fn no_usable_moves_yields_nothing() { + let c = content(); + let mut b = battle(&c, &[10, 0, 0, 0], 0); + b.foe.mon.moves[0].pp = 0; + let mut rng = Rng::new(1); + assert_eq!(choose(&c, &b, &mut rng), None); + } + + #[test] + fn a_wild_creature_picks_at_random_across_all_slots() { + let c = content(); + let b = battle(&c, &[10, 11, 12, 0], 0); + let mut rng = Rng::new(2); + let mut seen = [0u32; 3]; + for _ in 0..600 { + let i = choose(&c, &b, &mut rng).unwrap(); + seen[i] += 1; + } + assert!(seen.iter().all(|&n| n > 100), "not uniform: {seen:?}"); + } + + #[test] + fn moves_with_no_pp_are_never_chosen() { + let c = content(); + let mut b = battle(&c, &[10, 11, 0, 0], 0); + b.foe.mon.moves[0].pp = 0; + let mut rng = Rng::new(3); + for _ in 0..200 { + assert_eq!(choose(&c, &b, &mut rng), Some(1)); + } + } + + #[test] + fn a_smart_trainer_favours_the_super_effective_move() { + let c = content(); + let dumb = battle(&c, &[10, 11, 0, 0], 0); + let smart = battle(&c, &[10, 11, 0, 0], 4); + let mut rng = Rng::new(5); + let dumb_best = (0..500).filter(|_| choose(&c, &dumb, &mut rng) == Some(1)).count(); + let smart_best = (0..500).filter(|_| choose(&c, &smart, &mut rng) == Some(1)).count(); + assert!(smart_best > dumb_best + 100, "smart {smart_best} vs dumb {dumb_best}"); + assert!(smart_best > 400, "class 4 should nearly always be right: {smart_best}"); + } + + #[test] + fn a_status_move_loses_value_once_it_is_redundant() { + let c = content(); + let mut b = battle(&c, &[12, 0, 0, 0], 4); + let fresh = score(&c, &b.foe, &b.player, 0); + b.player.mon.status = spec::status::SLEEP; + let redundant = score(&c, &b.foe, &b.player, 0); + assert!(fresh > redundant); + assert_eq!(redundant, 0); + } + + #[test] + fn an_ineffective_move_scores_nothing() { + let mut c = content(); + c.matchups.push(Matchup { attacker: NORMAL, defender: GRASS, multiplier: 0 }); + let b = battle(&c, &[10, 11, 0, 0], 4); + assert_eq!(score(&c, &b.foe, &b.player, 0), 0); + assert!(score(&c, &b.foe, &b.player, 1) > 0); + } + + #[test] + fn healing_scores_higher_the_more_damage_there_is() { + let mut c = content(); + c.moves.insert( + 13, + Move { + id: 13, + kind: NORMAL, + power: 0, + accuracy: 100, + pp: 10, + category: spec::category::STATUS, + effect: spec::effect::HEAL, + ..Default::default() + }, + ); + let mut b = battle(&c, &[13, 0, 0, 0], 4); + assert_eq!(score(&c, &b.foe, &b.player, 0), 0, "pointless at full HP"); + b.foe.mon.damage(b.foe.mon.max_hp / 2); + assert!(score(&c, &b.foe, &b.player, 0) > 50); + } + + #[test] + fn choices_are_reproducible_from_a_seed() { + let c = content(); + let b = battle(&c, &[10, 11, 12, 0], 2); + let run = || { + let mut rng = Rng::new(0xabc); + (0..50).map(|_| choose(&c, &b, &mut rng)).collect::>() + }; + assert_eq!(run(), run()); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/battle/catching.rs b/engine/pocketmon/crates/pocketmon-core/src/battle/catching.rs new file mode 100644 index 00000000..86400325 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/battle/catching.rs @@ -0,0 +1,202 @@ +//! The catch algorithm. +//! +//! Ported from upstream `src/battle/Catching.lua`. The original's three-stage +//! test is preserved because its *shape* is what makes catching feel right — +//! a hard gate on the species' catch rate, then a second gate on how hurt and +//! how afflicted the target is: +//! +//! 1. A master-tier ball always succeeds. +//! 2. Roll `n` in `0..=ballMax`; a status subtracts from it (sleep and freeze +//! help most). If the subtraction underflows, it is an immediate catch. +//! If `n` exceeds the species' catch rate, it breaks free. +//! 3. Compute `f` from the HP ratio and the ball's factor; catch when +//! `f >= 255`, otherwise on `rand(0..=255) < f`. +//! +//! The one deliberate simplification: the original converts `f` into a shake +//! count and animates 0-3 shakes. We collapse that into a single roll and let +//! the presentation layer always play three shakes, because the shake count +//! carries no information the player can act on. + +use crate::content::Content; +use crate::rng::Rng; +use crate::spec; + +use super::Battler; + +/// The ball tier that always succeeds. +pub const MASTER_TIER: u8 = 3; + +/// The HP-ratio divisor per ball tier. A smaller factor is a better ball. +fn ball_factor(tier: u8) -> u32 { + match tier { + 0 => 12, // standard + 1 => 8, // great: better on healthy targets + 2 => 12, // ultra: its edge is the wider `n` gate in stage 2 + _ => 8, + } +} + +/// The bonus a status subtracts from the stage-2 roll. +fn status_bonus(status: u8) -> u32 { + match status { + spec::status::SLEEP | spec::status::FREEZE => spec::CATCH_BONUS_SLEEP_FREEZE, + spec::status::NONE => spec::CATCH_BONUS_NONE, + _ => spec::CATCH_BONUS_OTHER, + } +} + +/// Attempt a catch. `tier` indexes `spec::BALL_RATE`. +pub fn attempt(content: &Content, target: &Battler, tier: u8, rng: &mut Rng) -> bool { + if tier >= MASTER_TIER { + return true; + } + let catch_rate = content + .species_of(target.mon.species) + .map(|s| s.catch_rate as u32) + .unwrap_or(0); + // A species with catch rate 0 can only be taken with a master-tier ball. + if catch_rate == 0 { + return false; + } + + let ball_max = spec::BALL_RATE + .get(tier as usize) + .copied() + .unwrap_or(spec::BALL_RATE[0]); + let n = rng.range(0, ball_max); + let bonus = status_bonus(target.mon.status); + let n = match n.checked_sub(bonus) { + // The subtraction going negative is an immediate catch. + None => return true, + Some(v) => v, + }; + if n > catch_rate { + return false; + } + + let max_hp = target.mon.max_hp.max(1) as u32; + let hp = target.mon.hp.max(1) as u32; + let f = (max_hp * 255 * 4 / (hp * ball_factor(tier)).max(1)).min(255); + if f >= 255 { + return true; + } + rng.byte() < f +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::Species; + use crate::mon::{Dvs, MonInstance}; + + fn content(catch_rate: u8) -> Content { + let mut c = Content::new(); + c.species.insert( + 1, + Species { + id: 1, + base_hp: 60, + base_atk: 60, + base_def: 60, + base_spd: 60, + base_spc: 60, + catch_rate, + ..Default::default() + }, + ); + c + } + + fn target(c: &Content) -> Battler { + let mon = MonInstance::with_moves(c, 1, 20, &[0; 4], Dvs::default()).unwrap(); + Battler::new(c, mon, -1) + } + + /// Catches out of `n` attempts with a fresh RNG stream. + fn rate(c: &Content, t: &Battler, tier: u8, n: u32, seed: u64) -> u32 { + let mut rng = Rng::new(seed); + (0..n).filter(|_| attempt(c, t, tier, &mut rng)).count() as u32 + } + + #[test] + fn a_master_tier_ball_always_works() { + let c = content(3); // brutally low catch rate + let t = target(&c); + let mut rng = Rng::new(1); + assert!((0..200).all(|_| attempt(&c, &t, MASTER_TIER, &mut rng))); + } + + #[test] + fn a_zero_catch_rate_species_resists_everything_but_a_master_ball() { + let c = content(0); + let t = target(&c); + let mut rng = Rng::new(2); + assert!((0..500).all(|_| !attempt(&c, &t, 0, &mut rng))); + assert!(attempt(&c, &t, MASTER_TIER, &mut rng)); + } + + #[test] + fn a_high_catch_rate_is_much_easier_than_a_low_one() { + let easy = content(255); + let hard = content(3); + let te = target(&easy); + let th = target(&hard); + let e = rate(&easy, &te, 0, 2000, 3); + let h = rate(&hard, &th, 0, 2000, 3); + assert!(e > h * 4, "easy {e} vs hard {h}"); + } + + #[test] + fn weakening_the_target_helps() { + let c = content(90); + let healthy = target(&c); + let mut hurt = target(&c); + hurt.mon.hp = 1; + let h = rate(&c, &healthy, 0, 3000, 5); + let w = rate(&c, &hurt, 0, 3000, 5); + assert!(w > h, "hurt {w} vs healthy {h}"); + } + + #[test] + fn sleep_helps_more_than_paralysis() { + let c = content(60); + let awake = target(&c); + let mut asleep = target(&c); + asleep.mon.status = spec::status::SLEEP; + let mut para = target(&c); + para.mon.status = spec::status::PARALYSIS; + let a = rate(&c, &awake, 0, 4000, 7); + let s = rate(&c, &asleep, 0, 4000, 7); + let p = rate(&c, ¶, 0, 4000, 7); + assert!(s > p, "sleep {s} vs paralysis {p}"); + assert!(p > a, "paralysis {p} vs awake {a}"); + } + + #[test] + fn a_better_ball_beats_a_worse_one() { + let c = content(45); + let t = target(&c); + let standard = rate(&c, &t, 0, 4000, 11); + let great = rate(&c, &t, 1, 4000, 11); + let ultra = rate(&c, &t, 2, 4000, 11); + assert!(great > standard, "great {great} vs standard {standard}"); + assert!(ultra > standard, "ultra {ultra} vs standard {standard}"); + } + + #[test] + fn a_ball_tier_past_the_table_does_not_panic() { + let c = content(45); + let t = target(&c); + let mut rng = Rng::new(13); + // Anything at or above MASTER_TIER short-circuits; the table lookup + // below it must still be bounds-safe. + assert!(attempt(&c, &t, 200, &mut rng)); + } + + #[test] + fn the_same_seed_reproduces_the_same_outcome() { + let c = content(45); + let t = target(&c); + assert_eq!(rate(&c, &t, 0, 500, 17), rate(&c, &t, 0, 500, 17)); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/battle/damage.rs b/engine/pocketmon/crates/pocketmon-core/src/battle/damage.rs new file mode 100644 index 00000000..6d1cf5a0 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/battle/damage.rs @@ -0,0 +1,729 @@ +//! Damage, critical hits and accuracy. +//! +//! Ported from upstream `src/battle/Damage.lua`, which in turn cites the +//! original engine's `GetDamage` / `CriticalHitTest` / `AdjustDamageForMoveType` +//! / `RandomizeDamage`. The quirks below are deliberate and each is switchable +//! through [`Ruleset`], because "faithful" and "sane" are both legitimate +//! choices for a *new* game built on these rules: +//! +//! - **Critical hits use the species' BASE speed**, not the in-battle speed. +//! - **A critical hit doubles the level** in the damage formula instead of +//! multiplying the result, and ignores stat stages entirely. +//! - **Focus Energy quarters the crit rate** instead of quadrupling it (the +//! famous shift-direction bug). +//! - **Type rows apply one at a time, each with its own floor**, so 0.5 x 0.5 +//! is `floor(floor(d/2)/2)` and not `d/4`. +//! - **A hit that floors to zero damage is reported as a miss**, not as 1. +//! - **Both stats are quartered when either exceeds 255**, losing low bits. + +use alloc::vec::Vec; + +use crate::content::{Content, Move}; +use crate::mon::stats::{self, stat}; +use crate::rng::Rng; +use crate::spec; + +use super::Battler; + +/// The switchable rules. [`Ruleset::faithful`] reproduces the original's +/// behavior, quirks included. +#[derive(Clone, Copy, Debug)] +pub struct Ruleset { + /// Crit chance reads the species base speed rather than the live stat. + pub crit_uses_base_speed: bool, + /// Focus Energy quarters the crit rate instead of quadrupling it. + pub focus_energy_bug: bool, + /// Critical hits ignore stat stages (and screens). + pub crit_ignores_stages: bool, + /// A 100%-accuracy move can still miss on a 255 roll. + pub one_in_256_miss: bool, + pub rand_min: u32, + pub rand_max: u32, +} + +impl Default for Ruleset { + fn default() -> Self { + Ruleset::faithful() + } +} + +impl Ruleset { + pub const fn faithful() -> Self { + Ruleset { + crit_uses_base_speed: true, + focus_energy_bug: true, + crit_ignores_stages: true, + one_in_256_miss: true, + rand_min: spec::RAND_MIN, + rand_max: spec::RAND_MAX, + } + } + + /// The quirks removed: crits read live speed, Focus Energy helps, and a + /// 100%-accuracy move always connects. + pub const fn modern() -> Self { + Ruleset { + crit_uses_base_speed: false, + focus_energy_bug: false, + crit_ignores_stages: false, + one_in_256_miss: false, + rand_min: spec::RAND_MIN, + rand_max: spec::RAND_MAX, + } + } +} + +/// What a damage roll produced. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Hit { + pub damage: u16, + pub crit: bool, + /// Combined effectiveness, x10 (10 = neutral, 0 = immune). + pub type_mult: u32, + /// The hit rounded down to nothing and counts as a miss. + pub fizzled: bool, +} + +/// Options for one damage computation. +#[derive(Clone, Copy, Debug, Default)] +pub struct Opts { + /// Force the crit result instead of rolling. + pub force_crit: Option, + /// Self-destruct halves the defender's defense. + pub explode: bool, + /// The confusion self-hit: no STAB, no type chart, no random factor. + pub typeless: bool, +} + +/// A left shift that saturates at 255 — the original's `sla` with its cap. +fn shl(x: u32) -> u32 { + (x * 2).min(255) +} + +/// Roll for a critical hit. +pub fn crit_roll( + rules: &Ruleset, + content: &Content, + attacker: &Battler, + mv: &Move, + rng: &mut Rng, +) -> bool { + let speed = if rules.crit_uses_base_speed { + content + .species_of(attacker.mon.species) + .map(|s| s.base_spd as u32) + .unwrap_or(0) + } else { + stats::apply_stage(attacker.stats.speed, attacker.stages.get(stat::SPEED)) as u32 + }; + let mut b = speed / 2; + if attacker.focus_energy { + if rules.focus_energy_bug { + b /= 2; // srl where the original meant sla + } else { + b = shl(shl(shl(b))); + } + } else { + b = shl(b); + } + if mv.high_crit() { + b = shl(shl(b)); + } else { + b /= 2; + } + rng.byte() < b +} + +/// Roll for accuracy. +pub fn accuracy_roll( + rules: &Ruleset, + mv: &Move, + attacker: &Battler, + defender: &Battler, + rng: &mut Rng, +) -> bool { + // An accuracy-boost item short-circuits the whole test, 1/256 included. + if attacker.x_accuracy { + return true; + } + // Moves that never miss (Swift) bypass the roll. + if mv.effect == spec::effect::SWIFT { + return true; + } + let base = mv.accuracy as u32 * 255 / 100; + let acc_stage = attacker.stages.get(stat::ACCURACY); + let eva_stage = defender.stages.get(stat::EVASION); + let mut acc = stats::apply_stage(base.min(255) as u16, acc_stage) as u32; + acc = acc.min(255); + acc = stats::apply_stage(acc as u16, -eva_stage) as u32; + acc = acc.min(255); + + if !rules.one_in_256_miss && mv.accuracy >= 100 && acc_stage >= eva_stage { + return true; + } + rng.byte() < acc +} + +/// Compute the damage of one hit. +pub fn compute( + rules: &Ruleset, + content: &Content, + attacker: &Battler, + defender: &Battler, + mv: &Move, + opts: Opts, + rng: &mut Rng, + rows: &mut Vec, +) -> Hit { + if mv.power == 0 || mv.category == spec::category::STATUS { + return Hit { damage: 0, crit: false, type_mult: spec::TYPE_SCALE, fizzled: false }; + } + + let crit = match opts.force_crit { + Some(c) => c, + None => crit_roll(rules, content, attacker, mv, rng), + }; + + // Gen 1 splits physical/special by the move's TYPE, with the move's own + // category as an override. + let special = if mv.category == spec::category::SPECIAL { + true + } else if mv.category == spec::category::PHYSICAL { + false + } else { + content.type_category(mv.kind) == spec::category::SPECIAL + }; + + let (atk_idx, def_idx) = if special { + (stat::SPECIAL, stat::SPECIAL) + } else { + (stat::ATTACK, stat::DEFENSE) + }; + + let (mut atk, mut dfn) = if crit && rules.crit_ignores_stages { + ( + attacker.stats.get(atk_idx) as u32, + defender.stats.get(def_idx) as u32, + ) + } else { + let mut a = + stats::apply_stage(attacker.stats.get(atk_idx), attacker.stages.get(atk_idx)) as u32; + let mut d = + stats::apply_stage(defender.stats.get(def_idx), defender.stages.get(def_idx)) as u32; + + // Burn halves physical attack, applied to the battle stat itself. + if !special && attacker.mon.status == spec::status::BURN && !attacker.haze_reset { + a = (a / 2).max(1); + } + // Screens double the effective defense; crits bypass them. + if !crit { + let screens = if opts.typeless { attacker } else { defender }; + if special && screens.light_screen { + d *= 2; + } + if !special && screens.reflect { + d *= 2; + } + } + (a, d) + }; + + // When either stat leaves byte range BOTH are quartered, low bits lost. + if atk > spec::STAT_SCALE_LIMIT || dfn > spec::STAT_SCALE_LIMIT { + atk = (atk / 4).max(1); + dfn = (dfn / 4).max(1); + } + if opts.explode { + dfn = (dfn / 2).max(1); + } + + let level = if crit { + attacker.mon.level as u32 * 2 + } else { + attacker.mon.level as u32 + }; + + let mut d = (2 * level / 5) + 2; + d = (d * mv.power as u32 * atk / dfn.max(1)) / 50; + d = d.min(spec::DAMAGE_CLAMP) + 2; + + let mut mult = spec::TYPE_SCALE; + if !opts.typeless { + // STAB + let (t1, t2) = attacker.types; + if mv.kind == t1 || mv.kind == t2 { + d = d * spec::STAB_NUM / spec::STAB_DEN; + } + mult = content.effectiveness(mv.kind, defender.types); + if mult == 0 { + return Hit { damage: 0, crit: false, type_mult: 0, fizzled: false }; + } + // Each matchup row applies separately, with its own floor. + content.matchup_rows(mv.kind, defender.types, rows); + for &m in rows.iter() { + d = d * m as u32 / spec::TYPE_SCALE; + } + if d == 0 { + // A tiny hit at 0.25x floors away: the original reports a miss + // rather than dealing a courtesy point of damage. + return Hit { damage: 0, crit: false, type_mult: mult, fizzled: true }; + } + } + + // The confusion self-hit skips the random factor along with the type chart. + if d > 1 && !opts.typeless { + let r = rng.range(rules.rand_min, rules.rand_max); + d = d * r / 255; + } + + Hit { + damage: d.max(1).min(u16::MAX as u32) as u16, + crit, + type_mult: mult, + fizzled: false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::battle::Battler; + use crate::content::{Matchup, Species, TypeDef}; + use crate::mon::{Dvs, MonInstance}; + + const NORMAL: u8 = 0; + const FIRE: u8 = 1; + const WATER: u8 = 2; + const GRASS: u8 = 3; + + fn content() -> Content { + let mut c = Content::new(); + c.types = alloc::vec![ + TypeDef { category: spec::category::PHYSICAL, name_key: 0 }, // normal + TypeDef { category: spec::category::SPECIAL, name_key: 0 }, // fire + TypeDef { category: spec::category::SPECIAL, name_key: 0 }, // water + TypeDef { category: spec::category::SPECIAL, name_key: 0 }, // grass + ]; + c.matchups = alloc::vec![ + Matchup { attacker: FIRE, defender: GRASS, multiplier: 20 }, + Matchup { attacker: FIRE, defender: WATER, multiplier: 5 }, + Matchup { attacker: WATER, defender: FIRE, multiplier: 20 }, + Matchup { attacker: GRASS, defender: WATER, multiplier: 20 }, + Matchup { attacker: NORMAL, defender: FIRE, multiplier: 0 }, // test immunity + ]; + for id in 1..=4u16 { + c.species.insert( + id, + Species { + id, + base_hp: 60, + base_atk: 60, + base_def: 60, + base_spd: 60, + base_spc: 60, + type1: (id - 1) as u8, + type2: (id - 1) as u8, + ..Default::default() + }, + ); + } + c + } + + fn battler(c: &Content, species: u16, level: u8) -> Battler { + let mon = + MonInstance::with_moves(c, species, level, &[0, 0, 0, 0], Dvs::default()).unwrap(); + Battler::new(c, mon, 0) + } + + fn mv(kind: u8, power: u8, category: u8) -> Move { + Move { id: 1, kind, power, accuracy: 100, pp: 20, category, ..Default::default() } + } + + /// Damage with the random factor pinned to its maximum, so results are + /// exact instead of a range. + fn max_roll_damage( + c: &Content, + a: &Battler, + d: &Battler, + m: &Move, + crit: bool, + ) -> Hit { + let mut rows = Vec::new(); + // range(217, 255) consumes one draw; seed hunting is not needed + // because we force the crit and then divide out the spread below. + let mut rng = Rng::new(1); + compute( + &Ruleset::faithful(), + c, + a, + d, + m, + Opts { force_crit: Some(crit), ..Default::default() }, + &mut rng, + &mut rows, + ) + } + + #[test] + fn status_and_zero_power_moves_deal_nothing() { + let c = content(); + let a = battler(&c, 1, 50); + let d = battler(&c, 1, 50); + let h = max_roll_damage(&c, &a, &d, &mv(NORMAL, 0, spec::category::PHYSICAL), false); + assert_eq!(h.damage, 0); + let h = max_roll_damage(&c, &a, &d, &mv(NORMAL, 100, spec::category::STATUS), false); + assert_eq!(h.damage, 0); + } + + #[test] + fn the_base_formula_matches_a_hand_computation() { + let c = content(); + let a = battler(&c, 1, 50); // normal type, so no STAB on a FIRE move + let d = battler(&c, 1, 50); + // Use a FIRE move against a NORMAL defender: no STAB, no matchup row. + let m = mv(FIRE, 40, spec::category::SPECIAL); + let mut rows = Vec::new(); + let mut rng = Rng::new(7); + let h = compute( + &Ruleset::faithful(), + &c, + &a, + &d, + &m, + Opts { force_crit: Some(false), ..Default::default() }, + &mut rng, + &mut rows, + ); + // d = (2*50/5)+2 = 22; 22 * 40 * spc / spc / 50 = 22*40/50 = 17; +2 = 19 + // then * r/255 with r in 217..=255 -> 16..=19 + assert!((16..=19).contains(&h.damage), "damage {} out of range", h.damage); + assert_eq!(h.type_mult, spec::TYPE_SCALE); + } + + #[test] + fn stab_multiplies_by_one_and_a_half() { + let c = content(); + let fire = battler(&c, 2, 50); // FIRE + let normal = battler(&c, 1, 50); + let d = battler(&c, 1, 50); + let m = mv(FIRE, 60, spec::category::SPECIAL); + let with = max_roll_damage(&c, &fire, &d, &m, false); + let without = max_roll_damage(&c, &normal, &d, &m, false); + // Same stats either way, so the only difference is STAB. + assert!(with.damage > without.damage); + assert!(with.damage as u32 * 2 <= without.damage as u32 * 3 + 4); + } + + #[test] + fn immunity_returns_zero_and_reports_it() { + let c = content(); + let a = battler(&c, 1, 50); + let fire = battler(&c, 2, 50); + let h = max_roll_damage(&c, &a, &fire, &mv(NORMAL, 100, spec::category::PHYSICAL), false); + assert_eq!(h.damage, 0); + assert_eq!(h.type_mult, 0); + assert!(!h.fizzled, "an immunity is not a fizzle"); + } + + #[test] + fn effectiveness_scales_the_result() { + let c = content(); + let a = battler(&c, 1, 50); // NORMAL attacker: no STAB either way + let water = battler(&c, 3, 50); + let grass = battler(&c, 4, 50); + let m = mv(FIRE, 80, spec::category::SPECIAL); + let super_eff = max_roll_damage(&c, &a, &grass, &m, false); + let resisted = max_roll_damage(&c, &a, &water, &m, false); + assert!(super_eff.damage > resisted.damage * 3); + assert_eq!(super_eff.type_mult, 20); + assert_eq!(resisted.type_mult, 5); + } + + #[test] + fn dual_type_rows_floor_independently() { + // A defender that is resisted twice takes floor(floor(d/2)/2), which is + // <= d/4 and can differ from it by a point. + let mut c = content(); + c.species.insert( + 9, + Species { + id: 9, + base_hp: 60, + base_atk: 60, + base_def: 60, + base_spd: 60, + base_spc: 60, + type1: WATER, + type2: NORMAL, + ..Default::default() + }, + ); + c.matchups.push(Matchup { attacker: FIRE, defender: NORMAL, multiplier: 5 }); + let a = battler(&c, 1, 50); + let d = battler(&c, 9, 50); + let mut rows = Vec::new(); + c.matchup_rows(FIRE, (WATER, NORMAL), &mut rows); + assert_eq!(rows.len(), 2, "both rows apply"); + let h = max_roll_damage(&c, &a, &d, &mv(FIRE, 80, spec::category::SPECIAL), false); + assert_eq!(h.type_mult, 2, "0.5 x 0.5 in x10 fixed point"); + assert!(h.damage >= 1); + } + + #[test] + fn a_critical_hit_doubles_the_level_not_the_damage() { + let c = content(); + let a = battler(&c, 1, 50); + let d = battler(&c, 1, 50); + let m = mv(FIRE, 60, spec::category::SPECIAL); + let normal = max_roll_damage(&c, &a, &d, &m, false); + let crit = max_roll_damage(&c, &a, &d, &m, true); + assert!(crit.damage > normal.damage); + assert!(crit.crit); + // Doubling the level roughly doubles the (2L/5 + 2) term, so the result + // is a bit under 2x — never exactly 2x, which is the tell. + assert!(crit.damage < normal.damage * 2 + 4); + } + + #[test] + fn critical_hits_ignore_stat_stages_and_screens() { + let c = content(); + let mut a = battler(&c, 1, 50); + let mut d = battler(&c, 1, 50); + // Defender maximally buffed and screened. + d.stages.shift(stat::SPECIAL, 6); + d.light_screen = true; + a.stages.shift(stat::SPECIAL, -6); + let m = mv(FIRE, 80, spec::category::SPECIAL); + let plain = max_roll_damage(&c, &a, &d, &m, false); + let crit = max_roll_damage(&c, &a, &d, &m, true); + assert!(crit.damage > plain.damage * 4, "{} vs {}", crit.damage, plain.damage); + } + + #[test] + fn burn_halves_physical_attack_only() { + let c = content(); + let mut a = battler(&c, 1, 50); + let d = battler(&c, 1, 50); + let phys = mv(NORMAL, 80, spec::category::PHYSICAL); + let spec_move = mv(FIRE, 80, spec::category::SPECIAL); + let healthy_phys = max_roll_damage(&c, &a, &d, &phys, false); + let healthy_spec = max_roll_damage(&c, &a, &d, &spec_move, false); + a.mon.status = spec::status::BURN; + let burned_phys = max_roll_damage(&c, &a, &d, &phys, false); + let burned_spec = max_roll_damage(&c, &a, &d, &spec_move, false); + assert!(burned_phys.damage < healthy_phys.damage); + assert_eq!(burned_spec.damage, healthy_spec.damage, "special is untouched"); + } + + #[test] + fn screens_double_defense_against_their_own_category() { + let c = content(); + let a = battler(&c, 1, 50); + let mut d = battler(&c, 1, 50); + let phys = mv(NORMAL, 80, spec::category::PHYSICAL); + let base = max_roll_damage(&c, &a, &d, &phys, false); + d.reflect = true; + let reflected = max_roll_damage(&c, &a, &d, &phys, false); + assert!(reflected.damage < base.damage); + // Light Screen does nothing against a physical move. + let mut d2 = battler(&c, 1, 50); + d2.light_screen = true; + assert_eq!(max_roll_damage(&c, &a, &d2, &phys, false).damage, base.damage); + } + + #[test] + fn explode_halves_the_defense() { + let c = content(); + let a = battler(&c, 1, 50); + let d = battler(&c, 1, 50); + let m = mv(NORMAL, 100, spec::category::PHYSICAL); + let mut rows = Vec::new(); + let mut rng = Rng::new(3); + let plain = compute( + &Ruleset::faithful(), + &c, + &a, + &d, + &m, + Opts { force_crit: Some(false), ..Default::default() }, + &mut rng, + &mut rows, + ); + let mut rng = Rng::new(3); + let boom = compute( + &Ruleset::faithful(), + &c, + &a, + &d, + &m, + Opts { force_crit: Some(false), explode: true, ..Default::default() }, + &mut rng, + &mut rows, + ); + assert!(boom.damage > plain.damage); + } + + #[test] + fn the_typeless_self_hit_is_deterministic() { + let c = content(); + let a = battler(&c, 2, 50); // FIRE, would normally get STAB + let d = battler(&c, 4, 50); // GRASS, would normally take 2x + let m = mv(FIRE, 40, spec::category::PHYSICAL); + let mut rows = Vec::new(); + let mut first = None; + for seed in 0..8u64 { + let mut rng = Rng::new(seed + 1); + let h = compute( + &Ruleset::faithful(), + &c, + &a, + &d, + &m, + Opts { force_crit: Some(false), typeless: true, ..Default::default() }, + &mut rng, + &mut rows, + ); + assert_eq!(h.type_mult, spec::TYPE_SCALE, "no type chart"); + match first { + None => first = Some(h.damage), + Some(v) => assert_eq!(h.damage, v, "no random factor"), + } + } + } + + #[test] + fn damage_is_at_least_one_when_it_connects() { + let c = content(); + let a = battler(&c, 1, 2); + let mut d = battler(&c, 1, 100); + d.stages.shift(stat::DEFENSE, 6); + let h = max_roll_damage(&c, &a, &d, &mv(NORMAL, 10, spec::category::PHYSICAL), false); + assert!(h.damage >= 1 || h.fizzled); + } + + #[test] + fn crit_rate_follows_base_speed() { + let mut c = content(); + // A fast species crits far more often than a slow one. + c.species.get_mut(&1).unwrap().base_spd = 200; + c.species.insert( + 5, + Species { id: 5, base_spd: 10, base_hp: 60, base_atk: 60, base_def: 60, base_spc: 60, ..Default::default() }, + ); + let fast = battler(&c, 1, 50); + let slow = battler(&c, 5, 50); + let m = mv(NORMAL, 40, spec::category::PHYSICAL); + let rules = Ruleset::faithful(); + let mut rng = Rng::new(11); + let fast_crits = (0..4000).filter(|_| crit_roll(&rules, &c, &fast, &m, &mut rng)).count(); + let slow_crits = (0..4000).filter(|_| crit_roll(&rules, &c, &slow, &m, &mut rng)).count(); + // base 200 -> ~200/512 = 39%; base 10 -> ~10/512 = 2% + assert!((1300..1900).contains(&fast_crits), "fast crits: {fast_crits}"); + assert!(slow_crits < 200, "slow crits: {slow_crits}"); + } + + #[test] + fn high_crit_moves_crit_far_more_often() { + let c = content(); + let a = battler(&c, 1, 50); + let plain = mv(NORMAL, 40, spec::category::PHYSICAL); + let mut slash = plain.clone(); + slash.flags |= spec::MOVE_FLAG_HIGH_CRIT; + let rules = Ruleset::faithful(); + let mut rng = Rng::new(13); + let plain_crits = (0..4000).filter(|_| crit_roll(&rules, &c, &a, &plain, &mut rng)).count(); + let slash_crits = (0..4000).filter(|_| crit_roll(&rules, &c, &a, &slash, &mut rng)).count(); + assert!(slash_crits > plain_crits * 4, "{slash_crits} vs {plain_crits}"); + } + + #[test] + fn focus_energy_lowers_the_crit_rate_under_the_faithful_ruleset() { + let c = content(); + let a = battler(&c, 1, 50); + let mut focused = battler(&c, 1, 50); + focused.focus_energy = true; + let m = mv(NORMAL, 40, spec::category::PHYSICAL); + + let faithful = Ruleset::faithful(); + let mut rng = Rng::new(17); + let plain = (0..6000).filter(|_| crit_roll(&faithful, &c, &a, &m, &mut rng)).count(); + let bugged = (0..6000).filter(|_| crit_roll(&faithful, &c, &focused, &m, &mut rng)).count(); + assert!(bugged < plain, "the bug must hurt: {bugged} vs {plain}"); + + // ...and helps once the ruleset says so. + let modern = Ruleset::modern(); + let mut rng = Rng::new(17); + let plain = (0..6000).filter(|_| crit_roll(&modern, &c, &a, &m, &mut rng)).count(); + let boosted = (0..6000).filter(|_| crit_roll(&modern, &c, &focused, &m, &mut rng)).count(); + assert!(boosted > plain, "modern Focus Energy must help: {boosted} vs {plain}"); + } + + #[test] + fn accuracy_stages_and_the_one_in_256_rule() { + let c = content(); + let a = battler(&c, 1, 50); + let d = battler(&c, 1, 50); + let perfect = mv(NORMAL, 40, spec::category::PHYSICAL); // accuracy 100 + + // Faithful: even a 100%-accuracy move misses about 1 in 256. + let faithful = Ruleset::faithful(); + let mut rng = Rng::new(19); + let misses = (0..20_000) + .filter(|_| !accuracy_roll(&faithful, &perfect, &a, &d, &mut rng)) + .count(); + assert!((40..140).contains(&misses), "1/256 misses: {misses}"); + + // Modern: it always connects. + let modern = Ruleset::modern(); + let mut rng = Rng::new(19); + assert!((0..2000).all(|_| accuracy_roll(&modern, &perfect, &a, &d, &mut rng))); + } + + #[test] + fn evasion_makes_a_move_miss_more_often() { + let c = content(); + let a = battler(&c, 1, 50); + let mut d = battler(&c, 1, 50); + let m = mv(NORMAL, 40, spec::category::PHYSICAL); + let rules = Ruleset::faithful(); + let mut rng = Rng::new(23); + let base = (0..4000).filter(|_| accuracy_roll(&rules, &m, &a, &d, &mut rng)).count(); + d.stages.shift(stat::EVASION, 6); + let evasive = (0..4000).filter(|_| accuracy_roll(&rules, &m, &a, &d, &mut rng)).count(); + assert!(evasive < base / 2, "{evasive} vs {base}"); + } + + #[test] + fn swift_and_x_accuracy_never_miss() { + let c = content(); + let mut a = battler(&c, 1, 50); + let mut d = battler(&c, 1, 50); + d.stages.shift(stat::EVASION, 6); + let rules = Ruleset::faithful(); + let mut rng = Rng::new(29); + + let mut swift = mv(NORMAL, 60, spec::category::PHYSICAL); + swift.effect = spec::effect::SWIFT; + swift.accuracy = 1; + assert!((0..500).all(|_| accuracy_roll(&rules, &swift, &a, &d, &mut rng))); + + let weak = mv(NORMAL, 40, spec::category::PHYSICAL); + a.x_accuracy = true; + assert!((0..500).all(|_| accuracy_roll(&rules, &weak, &a, &d, &mut rng))); + } + + #[test] + fn the_same_seed_reproduces_the_same_damage() { + let c = content(); + let a = battler(&c, 2, 50); + let d = battler(&c, 4, 50); + let m = mv(FIRE, 80, spec::category::SPECIAL); + let run = || { + let mut rng = Rng::new(0xbeef); + let mut rows: Vec = Vec::new(); + compute(&Ruleset::faithful(), &c, &a, &d, &m, Opts::default(), &mut rng, &mut rows) + }; + assert_eq!(run(), run()); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/battle/effects.rs b/engine/pocketmon/crates/pocketmon-core/src/battle/effects.rs new file mode 100644 index 00000000..c78fe31d --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/battle/effects.rs @@ -0,0 +1,613 @@ +//! Move effects. +//! +//! Ported from upstream `src/battle/MoveEffects.lua` + `Status.lua`. Effects +//! the core implements natively are listed in `spec::effect`; anything a +//! content author needs beyond them is expressible as a guest-side +//! `scriptHook`, which is why this table can stay closed. + +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use crate::content::{Content, Move}; +use crate::mon::stats::stat; +use crate::rng::Rng; +use crate::spec; + +use super::Battler; + +/// How many times a move hits this use. +pub fn hit_count(mv: &Move, rng: &mut Rng) -> u32 { + match mv.effect { + spec::effect::TWO_HIT => 2, + spec::effect::MULTI_HIT => { + // 2 and 3 hits are 3/8 each, 4 and 5 are 1/8 each. + match rng.range(0, 7) { + 0..=2 => 2, + 3..=5 => 3, + 6 => 4, + _ => 5, + } + } + _ => { + if mv.flags & spec::MOVE_FLAG_MULTI_HIT != 0 { + rng.range(2, 5) + } else { + 1 + } + } + } +} + +/// Damage overrides that ignore the damage formula entirely. +pub fn fixed_damage(mv: &Move, atk: &Battler, def: &Battler, rolled: u16) -> u16 { + match mv.effect { + // `effect_chance` doubles as the payload for fixed-damage moves. + spec::effect::FIXED_DAMAGE => mv.effect_chance as u16, + spec::effect::LEVEL_DAMAGE => atk.mon.level as u16, + spec::effect::SUPER_FANG => (def.mon.hp / 2).max(1), + spec::effect::OHKO => def.mon.hp, + _ => rolled, + } +} + +/// Try to inflict a status. Returns false when it could not stick. +fn inflict(def: &mut Battler, status: u8, rng: &mut Rng, out: &mut Vec, name: &str) -> bool { + if def.mon.status != spec::status::NONE { + return false; + } + if def.mist { + out.push(format!("{name} is protected by mist!")); + return false; + } + def.mon.status = status; + let verb = match status { + spec::status::SLEEP => { + def.mon.sleep = rng.range(1, 7) as u8; + "fell asleep" + } + spec::status::POISON | spec::status::BAD_POISON => "was poisoned", + spec::status::BURN => "was burned", + spec::status::FREEZE => "was frozen solid", + spec::status::PARALYSIS => "is paralyzed", + _ => "is afflicted", + }; + out.push(format!("{name} {verb}!")); + true +} + +/// Shift a stat stage with the right message. +fn shift(target: &mut Battler, idx: usize, delta: i32, out: &mut Vec, name: &str) { + if delta < 0 && target.mist { + out.push(format!("{name} is protected by mist!")); + return; + } + let label = match idx { + stat::ATTACK => "ATTACK", + stat::DEFENSE => "DEFENSE", + stat::SPEED => "SPEED", + stat::SPECIAL => "SPECIAL", + stat::ACCURACY => "ACCURACY", + stat::EVASION => "EVASION", + _ => "STAT", + }; + if target.stages.shift(idx, delta) { + let dir = if delta > 0 { "rose" } else { "fell" }; + out.push(format!("{name}'s {label} {dir}!")); + } else { + let edge = if delta > 0 { "higher" } else { "lower" }; + out.push(format!("{name}'s {label} won't go any {edge}!")); + } +} + +/// Apply a move's secondary effect after its damage has landed. +/// +/// `dealt` is the damage the defender actually took this use (0 for status +/// moves and for hits a substitute absorbed) — drain and recoil read it. +pub fn apply( + mv: &Move, + atk: &mut Battler, + def: &mut Battler, + dealt: u16, + content: &Content, + rng: &mut Rng, + out: &mut Vec, +) { + let atk_name = atk.name(content); + let def_name = def.name(content); + + // A secondary effect with a chance only fires on the roll; effects with + // chance 0 are the move's whole point and always apply. + let fires = mv.effect_chance == 0 || rng.percent(mv.effect_chance as u32); + + match mv.effect { + spec::effect::NONE => {} + + spec::effect::BURN_CHANCE => { + if fires { + inflict(def, spec::status::BURN, rng, out, &def_name); + } + } + spec::effect::FREEZE_CHANCE => { + if fires { + inflict(def, spec::status::FREEZE, rng, out, &def_name); + } + } + spec::effect::PARALYZE_CHANCE => { + if fires { + inflict(def, spec::status::PARALYSIS, rng, out, &def_name); + } + } + spec::effect::POISON_CHANCE => { + if fires { + inflict(def, spec::status::POISON, rng, out, &def_name); + } + } + spec::effect::SLEEP => { + if !inflict(def, spec::status::SLEEP, rng, out, &def_name) { + out.push("It failed!".to_string()); + } + } + spec::effect::CONFUSE => { + if def.confused == 0 { + def.confused = rng.range(2, 5) as u8; + out.push(format!("{def_name} became confused!")); + } else { + out.push("It failed!".to_string()); + } + } + spec::effect::FLINCH_CHANCE => { + if fires { + def.flinched = true; + } + } + + spec::effect::ATK_DOWN => shift(def, stat::ATTACK, -1, out, &def_name), + spec::effect::DEF_DOWN => shift(def, stat::DEFENSE, -1, out, &def_name), + spec::effect::SPD_DOWN => shift(def, stat::SPEED, -1, out, &def_name), + spec::effect::SPC_DOWN => shift(def, stat::SPECIAL, -1, out, &def_name), + spec::effect::ACC_DOWN => shift(def, stat::ACCURACY, -1, out, &def_name), + spec::effect::ATK_UP => shift(atk, stat::ATTACK, 1, out, &atk_name), + spec::effect::DEF_UP => shift(atk, stat::DEFENSE, 1, out, &atk_name), + spec::effect::SPD_UP => shift(atk, stat::SPEED, 1, out, &atk_name), + spec::effect::SPC_UP => shift(atk, stat::SPECIAL, 1, out, &atk_name), + + spec::effect::DRAIN | spec::effect::DREAM_EATER => { + if mv.effect == spec::effect::DREAM_EATER && def.mon.status != spec::status::SLEEP { + out.push("It failed!".to_string()); + return; + } + let heal = (dealt / 2).max(1); + let got = atk.mon.heal(heal); + if got > 0 { + out.push(format!("{atk_name} drained health!")); + } + } + spec::effect::RECOIL => { + let recoil = (dealt / 4).max(1); + atk.mon.damage(recoil); + out.push(format!("{atk_name} is hit by recoil!")); + } + spec::effect::EXPLODE => { + // The user faints whether or not the hit connected. + atk.mon.hp = 0; + out.push(format!("{atk_name} blew up!")); + } + + spec::effect::OHKO => { + if dealt > 0 { + out.push("It's a one-hit KO!".to_string()); + } + } + + spec::effect::HIGH_CRIT | spec::effect::SWIFT | spec::effect::MULTI_HIT + | spec::effect::TWO_HIT | spec::effect::FIXED_DAMAGE | spec::effect::LEVEL_DAMAGE + | spec::effect::SUPER_FANG | spec::effect::CHARGE => { + // Handled in hit_count / fixed_damage / the charge branch. + } + + spec::effect::HYPER_BEAM => { + // Only a hit that did not faint the target forces a recharge. + if !def.fainted() { + atk.recharging = true; + } + } + + spec::effect::REFLECT => { + atk.reflect = true; + out.push(format!("{atk_name} is shielded against physical attacks!")); + } + spec::effect::LIGHT_SCREEN => { + atk.light_screen = true; + out.push(format!("{atk_name} is shielded against special attacks!")); + } + spec::effect::MIST => { + atk.mist = true; + out.push(format!("{atk_name} is shrouded in mist!")); + } + spec::effect::FOCUS_ENERGY => { + atk.focus_energy = true; + out.push(format!("{atk_name} is getting pumped!")); + } + spec::effect::HAZE => { + atk.stages.reset(); + def.stages.reset(); + atk.haze_reset = true; + def.haze_reset = true; + out.push("All stat changes were eliminated!".to_string()); + } + + spec::effect::HEAL => { + let half = (atk.mon.max_hp / 2).max(1); + let got = atk.mon.heal(half); + if got > 0 { + out.push(format!("{atk_name} regained health!")); + } else { + out.push("It failed!".to_string()); + } + } + spec::effect::REST => { + if atk.mon.hp == atk.mon.max_hp { + out.push("It failed!".to_string()); + } else { + atk.mon.hp = atk.mon.max_hp; + atk.mon.status = spec::status::SLEEP; + atk.mon.sleep = 2; + out.push(format!("{atk_name} fell asleep and became healthy!")); + } + } + + spec::effect::TRAP => { + def.trapped = rng.range(2, 5) as u8; + out.push(format!("{def_name} was trapped!")); + } + spec::effect::LEECH_SEED => { + if def.seeded { + out.push("It failed!".to_string()); + } else { + def.seeded = true; + out.push(format!("{def_name} was seeded!")); + } + } + spec::effect::SUBSTITUTE => { + let cost = (atk.mon.max_hp / 4).max(1); + if atk.mon.hp <= cost { + out.push("It failed!".to_string()); + } else { + atk.mon.damage(cost); + atk.substitute = cost; + out.push(format!("{atk_name} made a substitute!")); + } + } + spec::effect::PAY_DAY => { + out.push("Coins scattered everywhere!".to_string()); + } + spec::effect::DISABLE => { + // Disabling a specific slot needs per-slot state the record does + // not carry; the closest honest behavior is to drain the target's + // last-used PP, which is what makes Disable a tempo move at all. + if let Some(slot) = def.mon.moves.iter_mut().find(|s| !s.empty() && s.pp > 0) { + slot.pp = slot.pp.saturating_sub(1); + out.push(format!("{def_name}'s move was disabled!")); + } else { + out.push("It failed!".to_string()); + } + } + spec::effect::CONVERSION => { + atk.types = def.types; + out.push(format!("{atk_name} changed type!")); + } + spec::effect::TRANSFORM => { + atk.types = def.types; + atk.stats = def.stats; + out.push(format!("{atk_name} transformed!")); + } + spec::effect::METRONOME | spec::effect::MIRROR_MOVE => { + // Both need to invoke another move mid-resolution, which the flat + // turn structure deliberately does not allow. Content that wants + // them raises a scriptHook instead of the core recursing. + out.push("It failed!".to_string()); + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::Species; + use crate::mon::{Dvs, MonInstance}; + + fn content() -> Content { + let mut c = Content::new(); + c.species.insert( + 1, + Species { + id: 1, + base_hp: 60, + base_atk: 60, + base_def: 60, + base_spd: 60, + base_spc: 60, + ..Default::default() + }, + ); + c + } + + fn battler(c: &Content) -> Battler { + let mon = MonInstance::with_moves(c, 1, 50, &[0; 4], Dvs::default()).unwrap(); + Battler::new(c, mon, 0) + } + + fn mv(effect: u8, chance: u8) -> Move { + Move { + id: 1, + kind: 0, + power: 50, + accuracy: 100, + pp: 20, + category: spec::category::PHYSICAL, + effect, + effect_chance: chance, + flags: 0, + name_key: 0, + desc_key: 0, + anim_id: 0, + } + } + + #[test] + fn multi_hit_stays_in_range_and_favours_two_and_three() { + let mut rng = Rng::new(1); + let m = mv(spec::effect::MULTI_HIT, 0); + let mut counts = [0u32; 6]; + for _ in 0..8000 { + let n = hit_count(&m, &mut rng); + assert!((2..=5).contains(&n)); + counts[n as usize] += 1; + } + assert!(counts[2] > counts[4] * 2, "{counts:?}"); + assert!(counts[3] > counts[5] * 2, "{counts:?}"); + } + + #[test] + fn two_hit_always_hits_twice_and_plain_moves_once() { + let mut rng = Rng::new(1); + assert_eq!(hit_count(&mv(spec::effect::TWO_HIT, 0), &mut rng), 2); + assert_eq!(hit_count(&mv(spec::effect::NONE, 0), &mut rng), 1); + } + + #[test] + fn fixed_damage_variants_ignore_the_roll() { + let c = content(); + let a = battler(&c); + let mut d = battler(&c); + d.mon.hp = 80; + assert_eq!(fixed_damage(&mv(spec::effect::FIXED_DAMAGE, 20), &a, &d, 999), 20); + assert_eq!(fixed_damage(&mv(spec::effect::LEVEL_DAMAGE, 0), &a, &d, 999), 50); + assert_eq!(fixed_damage(&mv(spec::effect::SUPER_FANG, 0), &a, &d, 999), 40); + assert_eq!(fixed_damage(&mv(spec::effect::OHKO, 0), &a, &d, 1), 80); + // Anything else keeps the rolled value. + assert_eq!(fixed_damage(&mv(spec::effect::NONE, 0), &a, &d, 37), 37); + } + + #[test] + fn super_fang_always_deals_at_least_one() { + let c = content(); + let a = battler(&c); + let mut d = battler(&c); + d.mon.hp = 1; + assert_eq!(fixed_damage(&mv(spec::effect::SUPER_FANG, 0), &a, &d, 0), 1); + } + + #[test] + fn a_status_only_sticks_on_a_clean_target() { + let c = content(); + let mut a = battler(&c); + let mut d = battler(&c); + let mut out = Vec::new(); + let mut rng = Rng::new(2); + apply(&mv(spec::effect::SLEEP, 0), &mut a, &mut d, 0, &c, &mut rng, &mut out); + assert_eq!(d.mon.status, spec::status::SLEEP); + assert!(d.mon.sleep >= 1 && d.mon.sleep <= 7); + // A second attempt fails rather than refreshing it. + out.clear(); + apply(&mv(spec::effect::SLEEP, 0), &mut a, &mut d, 0, &c, &mut rng, &mut out); + assert!(out.iter().any(|m| m.contains("failed"))); + } + + #[test] + fn mist_blocks_status_and_stat_drops_but_not_boosts() { + let c = content(); + let mut a = battler(&c); + let mut d = battler(&c); + d.mist = true; + let mut out = Vec::new(); + let mut rng = Rng::new(3); + apply(&mv(spec::effect::PARALYZE_CHANCE, 0), &mut a, &mut d, 0, &c, &mut rng, &mut out); + assert_eq!(d.mon.status, spec::status::NONE); + apply(&mv(spec::effect::ATK_DOWN, 0), &mut a, &mut d, 0, &c, &mut rng, &mut out); + assert_eq!(d.stages.get(stat::ATTACK), 0); + // The attacker's own boost is unaffected by the defender's mist. + apply(&mv(spec::effect::ATK_UP, 0), &mut a, &mut d, 0, &c, &mut rng, &mut out); + assert_eq!(a.stages.get(stat::ATTACK), 1); + } + + #[test] + fn drain_heals_half_and_recoil_costs_a_quarter() { + let c = content(); + let mut a = battler(&c); + let mut d = battler(&c); + let mut out = Vec::new(); + let mut rng = Rng::new(4); + a.mon.damage(60); + let before = a.mon.hp; + apply(&mv(spec::effect::DRAIN, 0), &mut a, &mut d, 40, &c, &mut rng, &mut out); + assert_eq!(a.mon.hp, before + 20); + + let before = a.mon.hp; + apply(&mv(spec::effect::RECOIL, 0), &mut a, &mut d, 40, &c, &mut rng, &mut out); + assert_eq!(a.mon.hp, before - 10); + } + + #[test] + fn dream_eater_needs_a_sleeping_target() { + let c = content(); + let mut a = battler(&c); + let mut d = battler(&c); + a.mon.damage(40); + let before = a.mon.hp; + let mut out = Vec::new(); + let mut rng = Rng::new(5); + apply(&mv(spec::effect::DREAM_EATER, 0), &mut a, &mut d, 40, &c, &mut rng, &mut out); + assert_eq!(a.mon.hp, before, "no heal against an awake target"); + d.mon.status = spec::status::SLEEP; + apply(&mv(spec::effect::DREAM_EATER, 0), &mut a, &mut d, 40, &c, &mut rng, &mut out); + assert!(a.mon.hp > before); + } + + #[test] + fn explode_faints_the_user() { + let c = content(); + let mut a = battler(&c); + let mut d = battler(&c); + let mut out = Vec::new(); + let mut rng = Rng::new(6); + apply(&mv(spec::effect::EXPLODE, 0), &mut a, &mut d, 100, &c, &mut rng, &mut out); + assert!(a.fainted()); + } + + #[test] + fn hyper_beam_only_recharges_when_the_target_survives() { + let c = content(); + let mut a = battler(&c); + let mut d = battler(&c); + let mut out = Vec::new(); + let mut rng = Rng::new(7); + apply(&mv(spec::effect::HYPER_BEAM, 0), &mut a, &mut d, 10, &c, &mut rng, &mut out); + assert!(a.recharging); + a.recharging = false; + d.mon.hp = 0; + apply(&mv(spec::effect::HYPER_BEAM, 0), &mut a, &mut d, 10, &c, &mut rng, &mut out); + assert!(!a.recharging, "a KO skips the recharge"); + } + + #[test] + fn haze_clears_both_sides() { + let c = content(); + let mut a = battler(&c); + let mut d = battler(&c); + a.stages.shift(stat::ATTACK, 4); + d.stages.shift(stat::DEFENSE, -3); + let mut out = Vec::new(); + let mut rng = Rng::new(8); + apply(&mv(spec::effect::HAZE, 0), &mut a, &mut d, 0, &c, &mut rng, &mut out); + assert_eq!(a.stages.get(stat::ATTACK), 0); + assert_eq!(d.stages.get(stat::DEFENSE), 0); + } + + #[test] + fn rest_refuses_at_full_health_and_sleeps_otherwise() { + let c = content(); + let mut a = battler(&c); + let mut d = battler(&c); + let mut out = Vec::new(); + let mut rng = Rng::new(9); + apply(&mv(spec::effect::REST, 0), &mut a, &mut d, 0, &c, &mut rng, &mut out); + assert!(out.iter().any(|m| m.contains("failed"))); + a.mon.damage(50); + out.clear(); + apply(&mv(spec::effect::REST, 0), &mut a, &mut d, 0, &c, &mut rng, &mut out); + assert_eq!(a.mon.hp, a.mon.max_hp); + assert_eq!(a.mon.status, spec::status::SLEEP); + } + + #[test] + fn substitute_costs_a_quarter_and_then_soaks_damage() { + let c = content(); + let mut a = battler(&c); + let mut d = battler(&c); + let mut out = Vec::new(); + let mut rng = Rng::new(10); + let max = a.mon.max_hp; + apply(&mv(spec::effect::SUBSTITUTE, 0), &mut a, &mut d, 0, &c, &mut rng, &mut out); + assert_eq!(a.substitute, (max / 4).max(1)); + assert_eq!(a.mon.hp, max - (max / 4).max(1)); + // The substitute takes the hit, the creature does not. + let hp = a.mon.hp; + assert_eq!(a.take_damage(5), 0); + assert_eq!(a.mon.hp, hp); + assert_eq!(a.substitute, (max / 4).max(1) - 5); + } + + #[test] + fn substitute_fails_when_it_would_be_fatal() { + let c = content(); + let mut a = battler(&c); + let mut d = battler(&c); + a.mon.hp = 1; + let mut out = Vec::new(); + let mut rng = Rng::new(11); + apply(&mv(spec::effect::SUBSTITUTE, 0), &mut a, &mut d, 0, &c, &mut rng, &mut out); + assert_eq!(a.substitute, 0); + assert!(out.iter().any(|m| m.contains("failed"))); + } + + #[test] + fn leech_seed_does_not_stack() { + let c = content(); + let mut a = battler(&c); + let mut d = battler(&c); + let mut out = Vec::new(); + let mut rng = Rng::new(12); + apply(&mv(spec::effect::LEECH_SEED, 0), &mut a, &mut d, 0, &c, &mut rng, &mut out); + assert!(d.seeded); + out.clear(); + apply(&mv(spec::effect::LEECH_SEED, 0), &mut a, &mut d, 0, &c, &mut rng, &mut out); + assert!(out.iter().any(|m| m.contains("failed"))); + } + + #[test] + fn a_secondary_chance_of_zero_always_fires() { + let c = content(); + let mut a = battler(&c); + let mut rng = Rng::new(13); + // chance 0 means "this is the move's whole point". + for _ in 0..20 { + let mut d = battler(&c); + let mut out = Vec::new(); + apply(&mv(spec::effect::BURN_CHANCE, 0), &mut a, &mut d, 10, &c, &mut rng, &mut out); + assert_eq!(d.mon.status, spec::status::BURN); + } + } + + #[test] + fn a_secondary_chance_fires_about_as_often_as_stated() { + let c = content(); + let mut a = battler(&c); + let mut rng = Rng::new(14); + let mut burns = 0; + for _ in 0..2000 { + let mut d = battler(&c); + let mut out = Vec::new(); + apply(&mv(spec::effect::BURN_CHANCE, 10), &mut a, &mut d, 10, &c, &mut rng, &mut out); + if d.mon.status == spec::status::BURN { + burns += 1; + } + } + assert!((140..260).contains(&burns), "10% of 2000 was {burns}"); + } + + #[test] + fn conversion_copies_the_defenders_types() { + let c = content(); + let mut a = battler(&c); + let mut d = battler(&c); + d.types = (3, 4); + let mut out = Vec::new(); + let mut rng = Rng::new(15); + apply(&mv(spec::effect::CONVERSION, 0), &mut a, &mut d, 0, &c, &mut rng, &mut out); + assert_eq!(a.types, (3, 4)); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/battle/mod.rs b/engine/pocketmon/crates/pocketmon-core/src/battle/mod.rs new file mode 100644 index 00000000..cd842eec --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/battle/mod.rs @@ -0,0 +1,993 @@ +//! The turn-based battle engine. +//! +//! Ported from upstream `src/battle/BattleState.lua` (4.2 kLOC, the largest +//! module in that project) plus its `TurnOrder`, `Status`, `MoveEffects`, +//! `Catching`, `Experience` and `TrainerAI` neighbours. +//! +//! ## Shape +//! +//! The battle is a state machine the guest drives through ops, never a loop +//! that blocks. [`Battle::phase`] says what the core is waiting for; the guest +//! renders the matching menu and answers with `chooseAction` / `chooseMove` / +//! `advance`. Everything in between — order, accuracy, damage, effects, +//! faints, experience — resolves inside one call and lands in a message queue +//! the player walks through with A. That is Law 3: no coroutines, no threads, +//! and a battle replays exactly from (seed, input tape). + +pub mod ai; +pub mod catching; +pub mod damage; +pub mod effects; + +use alloc::format; +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use crate::content::{Content, Move, Trainer}; +use crate::event::{EventQueue, MonEvent}; +use crate::mon::stats::{self, stat, Stages, Stats}; +use crate::mon::{growth, Dvs, MonInstance, Party}; +use crate::rng::Rng; +use crate::spec; + +pub use damage::{Hit, Opts, Ruleset}; + +/// Frames a battle message stays up before A is accepted (stops a mashed A +/// from blowing through a whole turn in three frames). +pub const MSG_HOLD: u16 = 8; + +/// One side's active creature plus everything that only exists in battle. +#[derive(Clone, Debug)] +pub struct Battler { + pub mon: MonInstance, + /// Stats snapshotted on send-out; stages apply on top. + pub stats: Stats, + pub stages: Stages, + pub types: (u8, u8), + pub reflect: bool, + pub light_screen: bool, + pub mist: bool, + pub focus_energy: bool, + pub x_accuracy: bool, + /// Turns of confusion left. + pub confused: u8, + /// Set for one turn by a flinch effect. + pub flinched: bool, + /// Turns left of a trapping move. + pub trapped: u8, + /// Substitute HP, 0 when there is none. + pub substitute: u16, + /// Leech Seed drains to the other side while set. + pub seeded: bool, + /// Must spend the next turn recharging. + pub recharging: bool, + /// Mid-charge move, holding the move id. + pub charging: u16, + /// Haze lifted the burn stat penalty until the next recompute. + pub haze_reset: bool, + /// Which party slot this came from (-1 for a wild foe). + pub slot: i32, +} + +impl Battler { + pub fn new(content: &Content, mon: MonInstance, slot: i32) -> Battler { + let stats = mon.stats(content); + let types = mon.types(content); + Battler { + mon, + stats, + stages: Stages::default(), + types, + reflect: false, + light_screen: false, + mist: false, + focus_energy: false, + x_accuracy: false, + confused: 0, + flinched: false, + trapped: 0, + substitute: 0, + seeded: false, + recharging: false, + charging: 0, + haze_reset: false, + slot, + } + } + + pub fn fainted(&self) -> bool { + self.mon.fainted() + } + + /// Display name: the nickname when set, otherwise the species name. + pub fn name(&self, content: &Content) -> String { + name_of(content, &self.mon) + } + + /// Effective speed for turn order. + pub fn speed(&self) -> u16 { + let base = stats::apply_stage(self.stats.speed, self.stages.get(stat::SPEED)); + // Paralysis quarters speed. + if self.mon.status == spec::status::PARALYSIS { + (base / 4).max(1) + } else { + base + } + } + + /// Clear everything that only lasts while this creature is out. + pub fn on_switch_out(&mut self) { + self.stages.reset(); + self.confused = 0; + self.flinched = false; + self.trapped = 0; + self.substitute = 0; + self.seeded = false; + self.recharging = false; + self.charging = 0; + self.focus_energy = false; + self.x_accuracy = false; + self.haze_reset = false; + // Screens are scoped to the creature that raised them rather than to + // the side: one fewer piece of hidden state, and a switch is already + // a tempo cost. + self.reflect = false; + self.light_screen = false; + } + + /// Apply damage, routing through a substitute when one is up. + /// Returns the damage the creature itself took. + pub fn take_damage(&mut self, amount: u16) -> u16 { + if self.substitute > 0 { + let absorbed = amount.min(self.substitute); + self.substitute -= absorbed; + return 0; + } + self.mon.damage(amount) + } +} + +/// What kind of battle this is. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Kind { + Wild, + Trainer(u16), +} + +/// The action a side committed to this turn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Committed { + Move(usize), + Item(u16), + Switch(usize), + Run, +} + +/// The whole battle. +pub struct Battle { + pub kind: Kind, + pub rules: Ruleset, + /// The player's whole party. The battle OWNS it for its duration: the + /// active creature is a working copy that is written back on every switch, + /// faint and turn end. Holding a clone next to a caller-owned party is how + /// a fainted lead ends up looking healthy to the switch menu, so the + /// ownership is deliberate rather than incidental. + pub party: Party, + pub player: Battler, + pub foe: Battler, + /// The trainer's remaining roster (empty for a wild battle). + pub foe_party: Vec, + pub foe_index: usize, + pub foe_ai: u8, + pub reward: u32, + pub phase: u8, + /// Messages waiting to be walked through with A. + pub messages: Vec, + pub msg_hold: u16, + pub outcome: Option, + pub turn: u32, + pub run_attempts: u32, + /// Set when the player must choose a replacement after a faint. + pub must_switch: bool, + /// The creature the player just caught, for the `caught` event. + pub caught: Option, + player_action: Option, + /// Scratch for the type-row loop, kept to avoid per-hit allocation. + rows: Vec, +} + +impl Battle { + /// Start a wild encounter. The party moves into the battle and comes back + /// out with [`Battle::take_party`]. + pub fn wild(content: &Content, party: Party, slot: usize, wild: MonInstance) -> Option { + let lead = party.get(slot)?.clone(); + let foe_name = name_of(content, &wild); + let lead_name = name_of(content, &lead); + let mut b = Battle::empty(Kind::Wild, content, party, lead, slot as i32, wild); + b.say(format!("Wild {foe_name} appeared!")); + b.say(format!("Go! {lead_name}!")); + Some(b) + } + + /// Start a trainer battle. Returns None when the roster is unusable. + pub fn trainer( + content: &Content, + party: Party, + slot: usize, + trainer: &Trainer, + rng: &mut Rng, + ) -> Option { + let lead = party.get(slot)?.clone(); + let mut roster: Vec = Vec::new(); + for tm in &trainer.party { + let dvs = Dvs::roll(rng); + if let Some(m) = MonInstance::with_moves(content, tm.species, tm.level, &tm.moves, dvs) { + roster.push(m); + } + } + if roster.is_empty() { + return None; + } + let first = roster.remove(0); + let trainer_name = content.string(trainer.name_key).to_string(); + let foe_name = name_of(content, &first); + let lead_name = name_of(content, &lead); + let mut b = Battle::empty(Kind::Trainer(trainer.id), content, party, lead, slot as i32, first); + b.foe_party = roster; + b.foe_ai = trainer.ai_class; + b.reward = trainer.reward_base as u32; + b.say(format!("{trainer_name} wants to fight!")); + b.say(format!("{trainer_name} sent out {foe_name}!")); + b.say(format!("Go! {lead_name}!")); + Some(b) + } + + fn empty( + kind: Kind, + content: &Content, + party: Party, + lead: MonInstance, + slot: i32, + foe: MonInstance, + ) -> Battle { + Battle { + kind, + rules: Ruleset::faithful(), + party, + player: Battler::new(content, lead, slot), + foe: Battler::new(content, foe, -1), + foe_party: Vec::new(), + foe_index: 0, + foe_ai: 0, + reward: 0, + phase: spec::phase::INTRO, + messages: Vec::new(), + msg_hold: MSG_HOLD, + outcome: None, + turn: 0, + run_attempts: 0, + must_switch: false, + caught: None, + player_action: None, + rows: Vec::new(), + } + } + + fn say(&mut self, s: String) { + self.messages.push(s); + if self.outcome.is_none() { + self.phase = spec::phase::MESSAGE; + } + } + + /// The message currently on screen. + pub fn message(&self) -> Option<&str> { + self.messages.first().map(String::as_str) + } + + /// True once the battle is over AND every message has been read. + pub fn finished(&self) -> bool { + self.outcome.is_some() && self.messages.is_empty() + } + + /// Advance one frame: only the message hold timer runs on its own. + pub fn tick(&mut self) { + if self.msg_hold > 0 { + self.msg_hold -= 1; + } + } + + /// The A button on a message. Returns true when it was consumed. + pub fn advance(&mut self, content: &Content, rng: &mut Rng) -> bool { + if self.messages.is_empty() || self.msg_hold > 0 { + return false; + } + self.messages.remove(0); + self.msg_hold = MSG_HOLD; + if self.messages.is_empty() { + self.after_messages(content, rng); + } + true + } + + /// What to do once the message queue drains. + fn after_messages(&mut self, content: &Content, rng: &mut Rng) { + let _ = rng; + if self.outcome.is_some() { + self.phase = spec::phase::ENDED; + return; + } + if self.must_switch { + self.phase = spec::phase::CHOOSE_SWITCH; + return; + } + // The foe fainted: either the trainer sends the next one, or we won. + if self.foe.fainted() { + if self.send_next_foe(content) { + return; + } + self.finish(spec::outcome::WIN, content); + return; + } + self.phase = spec::phase::CHOOSE_ACTION; + } + + /// The player picked a top-level action. + pub fn choose_action(&mut self, action: u8, content: &Content, rng: &mut Rng) { + if self.phase != spec::phase::CHOOSE_ACTION { + return; + } + match action { + spec::action::FIGHT => self.phase = spec::phase::CHOOSE_MOVE, + spec::action::SWAP => self.phase = spec::phase::CHOOSE_SWITCH, + spec::action::RUN => { + self.player_action = Some(Committed::Run); + self.resolve(content, rng); + } + _ => { + // BAG: the guest opens its own menu and answers with + // `chooseItem`; nothing to do until it does. + } + } + } + + /// The player picked a move slot. + pub fn choose_move(&mut self, idx: usize, content: &Content, rng: &mut Rng) { + if self.phase != spec::phase::CHOOSE_MOVE && self.phase != spec::phase::CHOOSE_ACTION { + return; + } + let Some(slot) = self.player.mon.moves.get(idx).copied() else { + return; + }; + if slot.empty() { + return; + } + if slot.pp == 0 { + self.say("No PP left for this move!".to_string()); + return; + } + self.player_action = Some(Committed::Move(idx)); + self.resolve(content, rng); + } + + /// The player used an item. + pub fn choose_item(&mut self, item: u16, content: &Content, rng: &mut Rng) { + if self.phase == spec::phase::ENDED || self.outcome.is_some() { + return; + } + // An item the content does not define must not cost the player a turn. + if !content.items.contains_key(&item) { + return; + } + self.player_action = Some(Committed::Item(item)); + self.resolve(content, rng); + } + + /// The player switched — voluntarily, or as the forced choice after a faint. + pub fn choose_switch(&mut self, slot: usize, content: &Content, rng: &mut Rng) { + let Some(mon) = self.party.get(slot) else { return }; + if mon.fainted() || slot as i32 == self.player.slot { + return; + } + // Write the outgoing creature back before swapping it out. + self.store_player(); + self.player.on_switch_out(); + let incoming = self.party.get(slot).cloned().expect("checked above"); + let name = name_of(content, &incoming); + self.player = Battler::new(content, incoming, slot as i32); + + if self.must_switch { + // A forced switch after a faint costs no turn. + self.must_switch = false; + self.say(format!("Go! {name}!")); + return; + } + self.say(format!("Go! {name}!")); + self.player_action = Some(Committed::Switch(slot)); + self.resolve(content, rng); + } + + /// Copy the active creature's live state back into its party slot. + /// + /// Called on every switch, faint and turn end, so `party` is always the + /// truth the switch menu and the post-battle world can read. + pub fn store_player(&mut self) { + if self.player.slot < 0 { + return; + } + let slot = self.player.slot as usize; + let snapshot = self.player.mon.clone(); + if let Some(dst) = self.party.get_mut(slot) { + *dst = snapshot; + } + } + + /// Hand the party back once the battle is over. + pub fn take_party(&mut self) -> Party { + self.store_player(); + core::mem::take(&mut self.party) + } + + /// Resolve one full turn from the committed player action. + fn resolve(&mut self, content: &Content, rng: &mut Rng) { + let Some(player_action) = self.player_action.take() else { + return; + }; + self.turn = self.turn.wrapping_add(1); + + match player_action { + Committed::Run => { + if self.try_run(rng) { + self.say("Got away safely!".to_string()); + self.finish(spec::outcome::RAN, content); + return; + } + self.say("Can't escape!".to_string()); + self.foe_turn(content, rng); + self.end_of_turn(content); + } + Committed::Item(item) => { + self.use_item(item, content, rng); + if self.outcome.is_some() { + return; + } + self.foe_turn(content, rng); + self.end_of_turn(content); + } + Committed::Switch(_) => { + self.foe_turn(content, rng); + self.end_of_turn(content); + } + Committed::Move(idx) => { + let foe_move = ai::choose(content, self, rng); + if self.player_moves_first(content, idx, foe_move, rng) { + self.player_uses(idx, content, rng); + if !self.anyone_down() { + self.foe_uses(foe_move, content, rng); + } + } else { + self.foe_uses(foe_move, content, rng); + if !self.anyone_down() { + self.player_uses(idx, content, rng); + } + } + self.end_of_turn(content); + } + } + } + + fn anyone_down(&self) -> bool { + self.player.fainted() || self.foe.fainted() + } + + /// Speed order: the priority flag wins outright, then raw speed, then a + /// coin flip (the original breaks exact ties with an RNG bit). + fn player_moves_first( + &self, + content: &Content, + player_idx: usize, + foe_idx: Option, + rng: &mut Rng, + ) -> bool { + let pp = self + .move_at(content, &self.player, player_idx) + .map(|m| m.flags & spec::MOVE_FLAG_PRIORITY != 0) + .unwrap_or(false); + let fp = foe_idx + .and_then(|i| self.move_at(content, &self.foe, i)) + .map(|m| m.flags & spec::MOVE_FLAG_PRIORITY != 0) + .unwrap_or(false); + if pp != fp { + return pp; + } + let ps = self.player.speed(); + let fs = self.foe.speed(); + if ps != fs { + return ps > fs; + } + rng.chance(1, 2) + } + + fn move_at<'c>(&self, content: &'c Content, b: &Battler, idx: usize) -> Option<&'c Move> { + let slot = b.mon.moves.get(idx)?; + if slot.empty() { + return None; + } + content.move_of(slot.id) + } + + fn player_uses(&mut self, idx: usize, content: &Content, rng: &mut Rng) { + let mut atk = core::mem::replace(&mut self.player, placeholder()); + let mut def = core::mem::replace(&mut self.foe, placeholder()); + self.execute(&mut atk, &mut def, idx, content, rng); + self.player = atk; + self.foe = def; + } + + fn foe_uses(&mut self, idx: Option, content: &Content, rng: &mut Rng) { + let Some(idx) = idx else { + let name = self.foe.name(content); + self.say(format!("{name} has no moves left!")); + return; + }; + let mut atk = core::mem::replace(&mut self.foe, placeholder()); + let mut def = core::mem::replace(&mut self.player, placeholder()); + self.execute(&mut atk, &mut def, idx, content, rng); + self.foe = atk; + self.player = def; + } + + fn foe_turn(&mut self, content: &Content, rng: &mut Rng) { + if self.foe.fainted() { + return; + } + let choice = ai::choose(content, self, rng); + self.foe_uses(choice, content, rng); + } + + /// One creature uses one move, start to finish. + fn execute( + &mut self, + atk: &mut Battler, + def: &mut Battler, + idx: usize, + content: &Content, + rng: &mut Rng, + ) { + if atk.fainted() { + return; + } + self.phase = spec::phase::MESSAGE; + let name = atk.name(content); + + if atk.recharging { + atk.recharging = false; + self.messages.push(format!("{name} must recharge!")); + return; + } + if atk.flinched { + atk.flinched = false; + self.messages.push(format!("{name} flinched!")); + return; + } + match atk.mon.status { + spec::status::SLEEP => { + atk.mon.sleep = atk.mon.sleep.saturating_sub(1); + if atk.mon.sleep == 0 { + atk.mon.status = spec::status::NONE; + self.messages.push(format!("{name} woke up!")); + } else { + self.messages.push(format!("{name} is fast asleep!")); + return; + } + } + spec::status::FREEZE => { + self.messages.push(format!("{name} is frozen solid!")); + return; + } + spec::status::PARALYSIS => { + if rng.percent(25) { + self.messages.push(format!("{name} is fully paralyzed!")); + return; + } + } + _ => {} + } + if atk.confused > 0 { + atk.confused -= 1; + if atk.confused == 0 { + self.messages.push(format!("{name} snapped out of confusion!")); + } else { + self.messages.push(format!("{name} is confused!")); + if rng.chance(1, 2) { + // The self-hit reads the OPPONENT's screens, hence passing + // `def` as the screen source through Opts::typeless. + let hit = damage::compute( + &self.rules, + content, + atk, + atk, + &confusion_move(), + Opts { force_crit: Some(false), typeless: true, ..Default::default() }, + rng, + &mut self.rows, + ); + atk.mon.damage(hit.damage); + self.messages.push("It hurt itself in its confusion!".to_string()); + return; + } + } + } + + let Some(slot) = atk.mon.moves.get(idx).copied() else { + return; + }; + let Some(mv) = content.move_of(slot.id).cloned() else { + return; + }; + let move_name = content.string(mv.name_key).to_string(); + + // A charge move spends its first turn announcing. + if mv.flags & spec::MOVE_FLAG_CHARGE != 0 && atk.charging != mv.id { + atk.charging = mv.id; + if let Some(s) = atk.mon.moves.get_mut(idx) { + s.pp = s.pp.saturating_sub(1); + } + self.messages.push(format!("{name} used {move_name}!")); + self.messages.push(format!("{name} is charging!")); + return; + } + atk.charging = 0; + + if let Some(s) = atk.mon.moves.get_mut(idx) { + if s.pp == 0 { + self.messages.push(format!("{name} has no PP left!")); + return; + } + s.pp -= 1; + } + self.messages.push(format!("{name} used {move_name}!")); + + if !damage::accuracy_roll(&self.rules, &mv, atk, def, rng) { + self.messages.push(format!("{name}'s attack missed!")); + return; + } + + let mut total = 0u16; + if mv.power > 0 && mv.category != spec::category::STATUS { + let hits = effects::hit_count(&mv, rng); + let mut landed = 0; + for _ in 0..hits { + if def.fainted() { + break; + } + let hit = damage::compute( + &self.rules, + content, + atk, + def, + &mv, + Opts { explode: mv.effect == spec::effect::EXPLODE, ..Default::default() }, + rng, + &mut self.rows, + ); + if hit.type_mult == 0 { + let dn = def.name(content); + self.messages.push(format!("It doesn't affect {dn}!")); + return; + } + if hit.fizzled { + self.messages.push(format!("{name}'s attack missed!")); + return; + } + let dealt = effects::fixed_damage(&mv, atk, def, hit.damage); + total = total.saturating_add(def.take_damage(dealt)); + landed += 1; + if hit.crit { + self.messages.push("A critical hit!".to_string()); + } + if hit.type_mult > spec::TYPE_SCALE { + self.messages.push("It's super effective!".to_string()); + } else if hit.type_mult < spec::TYPE_SCALE { + self.messages.push("It's not very effective...".to_string()); + } + } + if landed > 1 { + self.messages.push(format!("Hit {landed} times!")); + } + } + + effects::apply(&mv, atk, def, total, content, rng, &mut self.messages); + } + + /// Residual damage, leech seed and trap countdown, then faint handling. + fn end_of_turn(&mut self, content: &Content) { + for side in 0..2 { + let (b, other) = if side == 0 { + (&mut self.player, &mut self.foe) + } else { + (&mut self.foe, &mut self.player) + }; + if b.fainted() { + continue; + } + let name = name_of(content, &b.mon); + match b.mon.status { + spec::status::POISON | spec::status::BAD_POISON => { + let d = (b.mon.max_hp / 16).max(1); + b.mon.damage(d); + self.messages.push(format!("{name} is hurt by poison!")); + } + spec::status::BURN => { + let d = (b.mon.max_hp / 16).max(1); + b.mon.damage(d); + self.messages.push(format!("{name} is hurt by its burn!")); + } + _ => {} + } + if b.seeded && !b.fainted() { + let d = (b.mon.max_hp / 16).max(1); + let taken = b.mon.damage(d); + other.mon.heal(taken); + self.messages.push(format!("{name}'s health is sapped!")); + } + if b.trapped > 0 { + b.trapped -= 1; + } + } + self.check_faints(content); + self.store_player(); + if self.outcome.is_none() && self.messages.is_empty() && !self.must_switch { + self.phase = spec::phase::CHOOSE_ACTION; + } + } + + /// Queue faint messages and award experience. Returns true if anyone fell. + fn check_faints(&mut self, content: &Content) -> bool { + let mut fell = false; + if self.foe.fainted() { + let n = self.foe.name(content); + self.messages.push(format!("{n} fainted!")); + self.award_exp(content); + fell = true; + } + if self.player.fainted() { + let n = self.player.name(content); + self.messages.push(format!("{n} fainted!")); + self.store_player(); + // No one left to send out is a loss, not a switch prompt — the + // desync that made this check unreliable is why the party lives + // in the battle now. + if self.party.wiped() { + self.finish(spec::outcome::LOSS, content); + } else { + self.must_switch = true; + } + fell = true; + } + if fell { + self.phase = spec::phase::MESSAGE; + } + fell + } + + /// Grant experience for the defeated foe to the active creature. + fn award_exp(&mut self, content: &Content) { + if self.player.fainted() { + return; + } + let Some(sp) = content.species_of(self.foe.mon.species) else { + return; + }; + let trainer = matches!(self.kind, Kind::Trainer(_)); + let amount = growth::exp_award(sp.base_exp, self.foe.mon.level, trainer, 1); + let name = self.player.name(content); + self.messages.push(format!("{name} gained {amount} EXP!")); + self.player.mon.stat_exp.add(sp); + let levels = self.player.mon.gain_exp(content, amount); + if levels > 0 { + // The battle snapshot must follow the level-up, or the rest of the + // fight keeps using the pre-level stats. + self.player.stats = self.player.mon.stats(content); + let lv = self.player.mon.level; + self.messages.push(format!("{name} grew to level {lv}!")); + for mv in self.player.mon.moves_learned_at(content, lv) { + let key = content.move_of(mv).map(|m| m.name_key).unwrap_or(0); + let mv_name = content.string(key).to_string(); + if self.player.mon.learn(content, mv) { + self.messages.push(format!("{name} learned {mv_name}!")); + } + } + } + } + + /// Send the trainer's next creature. Returns false when they are out. + fn send_next_foe(&mut self, content: &Content) -> bool { + if self.foe_party.is_empty() { + return false; + } + let next = self.foe_party.remove(0); + self.foe_index += 1; + let name = name_of(content, &next); + self.foe = Battler::new(content, next, -1); + self.say(format!("Opponent sent out {name}!")); + true + } + + /// The escape formula: faster always gets away; otherwise + /// `odds = playerSpeed * 32 / (foeSpeed / 4) + 30 * attempts` out of 256. + fn try_run(&mut self, rng: &mut Rng) -> bool { + if let Kind::Trainer(_) = self.kind { + return false; + } + self.run_attempts += 1; + let ps = self.player.speed() as u32; + let fs_raw = self.foe.speed() as u32; + if ps > fs_raw { + return true; + } + let fs = (fs_raw / 4).max(1); + let odds = (ps * 32 / fs) + 30 * self.run_attempts; + if odds >= 256 { + return true; + } + rng.byte() < odds + } + + /// Use a bag item in battle. + fn use_item(&mut self, item_id: u16, content: &Content, rng: &mut Rng) { + let Some(item) = content.items.get(&item_id).cloned() else { + return; + }; + let item_name = content.string(item.name_key).to_string(); + self.say(format!("Used {item_name}!")); + match item.kind { + spec::item_kind::BALL => { + if let Kind::Trainer(_) = self.kind { + self.messages.push("The trainer blocked the ball!".to_string()); + return; + } + let caught = catching::attempt(content, &self.foe, item.param, rng); + let foe_name = self.foe.name(content); + if caught { + self.messages.push(format!("{foe_name} was caught!")); + self.caught = Some(self.foe.mon.clone()); + self.finish(spec::outcome::CAUGHT, content); + } else { + self.messages.push("It broke free!".to_string()); + } + } + spec::item_kind::HEAL => { + let healed = self.player.mon.heal(item.param as u16 * 10); + let name = self.player.name(content); + self.messages.push(format!("{name} recovered {healed} HP!")); + } + spec::item_kind::STATUS => { + self.player.mon.status = spec::status::NONE; + self.player.mon.sleep = 0; + let name = self.player.name(content); + self.messages.push(format!("{name} is cured!")); + } + spec::item_kind::REVIVE => { + let name = self.player.name(content); + self.messages.push(format!("{name} is already up!")); + } + spec::item_kind::BOOST => { + self.player.stages.shift(item.param as usize, 1); + self.messages.push("Stats rose!".to_string()); + } + spec::item_kind::ESCAPE => { + if let Kind::Wild = self.kind { + self.messages.push("Got away safely!".to_string()); + self.finish(spec::outcome::RAN, content); + } else { + self.messages.push("It failed!".to_string()); + } + } + _ => self.messages.push("It had no effect!".to_string()), + } + } + + /// End the battle with an outcome. + pub fn finish(&mut self, outcome: u8, content: &Content) { + if self.outcome.is_some() { + return; + } + self.outcome = Some(outcome); + self.must_switch = false; + if outcome == spec::outcome::WIN { + if let Kind::Trainer(_) = self.kind { + let money = self.reward.max(1) * self.foe.mon.level.max(1) as u32; + self.reward = money; + self.messages.push(format!("Got ${money} for winning!")); + } + } + if outcome == spec::outcome::LOSS { + self.messages.push("You blacked out!".to_string()); + } + let _ = content; + self.phase = if self.messages.is_empty() { + spec::phase::ENDED + } else { + spec::phase::MESSAGE + }; + } + + /// Force a loss (the guest's "give up" path; a wipe is detected natively). + pub fn player_wiped(&mut self, content: &Content) { + self.finish(spec::outcome::LOSS, content); + } + + /// Emit the end-of-battle fact for the guest. + pub fn emit_end(&self, events: &mut EventQueue) { + if let Some(outcome) = self.outcome { + let trainer = match self.kind { + Kind::Trainer(id) => id as i32, + Kind::Wild => -1, + }; + events.push(MonEvent { + kind: spec::event::BATTLE_ENDED, + a: outcome as u16, + b: trainer, + c: self.reward as i32, + d: 0, + }); + } + } +} + +/// A stand-in used while the two sides are temporarily moved out to satisfy +/// the borrow checker; never observed. +fn placeholder() -> Battler { + Battler { + mon: MonInstance::default(), + stats: Stats::default(), + stages: Stages::default(), + types: (0, 0), + reflect: false, + light_screen: false, + mist: false, + focus_energy: false, + x_accuracy: false, + confused: 0, + flinched: false, + trapped: 0, + substitute: 0, + seeded: false, + recharging: false, + charging: 0, + haze_reset: false, + slot: -1, + } +} + +/// The synthetic move the confusion self-hit uses: 40 power, typeless. +fn confusion_move() -> Move { + Move { + id: 0, + kind: 0, + power: 40, + accuracy: 100, + pp: 0, + category: spec::category::PHYSICAL, + effect: spec::effect::NONE, + effect_chance: 0, + flags: 0, + name_key: 0, + desc_key: 0, + anim_id: 0, + } +} + +fn name_of(content: &Content, m: &MonInstance) -> String { + if m.nickname_key != 0 { + return content.string(m.nickname_key).to_string(); + } + match content.species_of(m.species) { + Some(s) => content.string(s.name_key).to_string(), + None => "???".to_string(), + } +} + +#[cfg(test)] +mod tests; diff --git a/engine/pocketmon/crates/pocketmon-core/src/battle/tests.rs b/engine/pocketmon/crates/pocketmon-core/src/battle/tests.rs new file mode 100644 index 00000000..ccb31891 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/battle/tests.rs @@ -0,0 +1,643 @@ +//! Battle integration tests: whole fights, driven the way the guest drives +//! them (ops in, messages out), not through internal helpers. + +use super::*; +use crate::content::{Item, Matchup, Move, Species, TypeDef}; +use crate::mon::Dvs; +use alloc::vec; + +const NORMAL: u8 = 0; +const FIRE: u8 = 1; +const GRASS: u8 = 2; + +/// String keys used by the fixture content. +mod key { + pub const BLANK: u16 = 0; + pub const ROOKIE: u16 = 1; + pub const SPROUT: u16 = 2; + pub const TACKLE: u16 = 3; + pub const EMBER: u16 = 4; + pub const LULLABY: u16 = 5; + pub const BALL: u16 = 6; + pub const RIVAL: u16 = 7; + pub const POTION: u16 = 8; +} + +fn fixture() -> Content { + let mut c = Content::new(); + c.strings = vec![ + String::new(), + "ROOKIE".to_string(), + "SPROUT".to_string(), + "TACKLE".to_string(), + "EMBER".to_string(), + "LULLABY".to_string(), + "BALL".to_string(), + "RIVAL".to_string(), + "POTION".to_string(), + ]; + c.types = vec![ + TypeDef { category: spec::category::PHYSICAL, name_key: key::BLANK }, + TypeDef { category: spec::category::SPECIAL, name_key: key::BLANK }, + TypeDef { category: spec::category::SPECIAL, name_key: key::BLANK }, + ]; + c.matchups = vec![Matchup { attacker: FIRE, defender: GRASS, multiplier: 20 }]; + + // 1 = ROOKIE (normal, fast), 2 = SPROUT (grass, slow and frail) + c.species.insert( + 1, + Species { + id: 1, + base_hp: 60, + base_atk: 70, + base_def: 50, + base_spd: 90, + base_spc: 50, + type1: NORMAL, + type2: NORMAL, + catch_rate: 200, + base_exp: 64, + growth: spec::growth::MEDIUM_FAST, + name_key: key::ROOKIE, + learn_offset: 0, + learn_count: 2, + ..Default::default() + }, + ); + c.species.insert( + 2, + Species { + id: 2, + base_hp: 40, + base_atk: 40, + base_def: 40, + base_spd: 20, + base_spc: 40, + type1: GRASS, + type2: GRASS, + catch_rate: 200, + base_exp: 50, + growth: spec::growth::MEDIUM_FAST, + name_key: key::SPROUT, + learn_offset: 0, + learn_count: 1, + ..Default::default() + }, + ); + c.learn_pool = vec![ + crate::content::Learn { level: 1, move_id: 10 }, + crate::content::Learn { level: 5, move_id: 11 }, + ]; + + c.moves.insert( + 10, + Move { + id: 10, + kind: NORMAL, + power: 40, + accuracy: 100, + pp: 35, + category: spec::category::PHYSICAL, + name_key: key::TACKLE, + ..Default::default() + }, + ); + c.moves.insert( + 11, + Move { + id: 11, + kind: FIRE, + power: 90, + accuracy: 100, + pp: 15, + category: spec::category::SPECIAL, + name_key: key::EMBER, + ..Default::default() + }, + ); + c.moves.insert( + 12, + Move { + id: 12, + kind: NORMAL, + power: 0, + accuracy: 100, + pp: 20, + category: spec::category::STATUS, + effect: spec::effect::SLEEP, + name_key: key::LULLABY, + ..Default::default() + }, + ); + c.items.insert( + 1, + Item { id: 1, name_key: key::BALL, kind: spec::item_kind::BALL, param: 0, ..Default::default() }, + ); + c.items.insert( + 2, + Item { + id: 2, + name_key: key::POTION, + kind: spec::item_kind::HEAL, + param: 2, // 20 HP + ..Default::default() + }, + ); + c +} + +fn mon(c: &Content, species: u16, level: u8, moves: &[u16]) -> MonInstance { + MonInstance::with_moves(c, species, level, moves, Dvs::perfect()).unwrap() +} + +/// A party holding exactly these creatures. +fn party_of(mons: Vec) -> Party { + let mut p = Party::default(); + for m in mons { + p.add(m); + } + p +} + +/// The common case: a one-creature party. +fn solo(c: &Content, species: u16, level: u8, moves: &[u16]) -> Party { + party_of(vec![mon(c, species, level, moves)]) +} + +/// Start a wild battle against `foe` with `party`. +fn wild(c: &Content, party: Party, foe: MonInstance) -> Battle { + Battle::wild(c, party, 0, foe).expect("battle starts") +} + +/// Walk every queued message, then report the phase we settled on. +fn flush(b: &mut Battle, c: &Content, rng: &mut Rng) { + for _ in 0..400 { + if b.messages.is_empty() { + break; + } + b.msg_hold = 0; + b.advance(c, rng); + } +} + +/// Play a whole battle: keep picking move slot 0 until it ends. +fn play_out(b: &mut Battle, c: &Content, rng: &mut Rng) -> u8 { + for _ in 0..400 { + flush(b, c, rng); + match b.phase { + spec::phase::CHOOSE_ACTION => { + b.choose_action(spec::action::FIGHT, c, rng); + b.choose_move(0, c, rng); + } + spec::phase::CHOOSE_MOVE => b.choose_move(0, c, rng), + spec::phase::CHOOSE_SWITCH => { + match b.party.mons.iter().position(|m| !m.fainted()) { + Some(slot) => b.choose_switch(slot, c, rng), + None => b.player_wiped(c), + } + } + spec::phase::ENDED => return b.outcome.unwrap_or(spec::outcome::DRAW), + _ => {} + } + } + panic!("battle did not terminate; phase = {}", b.phase); +} + +#[test] +fn a_wild_battle_opens_with_its_intro_messages() { + let c = fixture(); + let mut rng = Rng::new(1); + let b = wild(&c, solo(&c, 1, 10, &[10, 0, 0, 0]), mon(&c, 2, 5, &[10, 0, 0, 0])); + assert_eq!(b.phase, spec::phase::MESSAGE); + assert_eq!(b.message(), Some("Wild SPROUT appeared!")); + let _ = rng.byte(); +} + +#[test] +fn messages_hold_before_accepting_a() { + let c = fixture(); + let mut rng = Rng::new(1); + let mut b = wild(&c, solo(&c, 1, 10, &[10, 0, 0, 0]), mon(&c, 2, 5, &[10, 0, 0, 0])); + // A mashed A on the first frame is ignored until the hold expires. + assert!(!b.advance(&c, &mut rng)); + for _ in 0..MSG_HOLD { + b.tick(); + } + assert!(b.advance(&c, &mut rng)); +} + +#[test] +fn reading_the_intro_lands_on_the_action_menu() { + let c = fixture(); + let mut rng = Rng::new(1); + let mut b = wild(&c, solo(&c, 1, 10, &[10, 0, 0, 0]), mon(&c, 2, 5, &[10, 0, 0, 0])); + flush(&mut b, &c, &mut rng); + assert_eq!(b.phase, spec::phase::CHOOSE_ACTION); +} + +#[test] +fn a_strong_lead_wins_a_wild_battle() { + let c = fixture(); + let mut rng = Rng::new(7); + let mut b = wild(&c, solo(&c, 1, 30, &[10, 0, 0, 0]), mon(&c, 2, 3, &[10, 0, 0, 0])); + assert_eq!(play_out(&mut b, &c, &mut rng), spec::outcome::WIN); + assert!(b.foe.fainted()); +} + +#[test] +fn losing_every_creature_ends_the_battle_in_a_loss() { + let c = fixture(); + let mut rng = Rng::new(9); + let mut b = wild(&c, solo(&c, 2, 2, &[10, 0, 0, 0]), mon(&c, 1, 40, &[10, 0, 0, 0])); + assert_eq!(play_out(&mut b, &c, &mut rng), spec::outcome::LOSS); +} + +#[test] +fn winning_grants_experience_and_can_level_up() { + let c = fixture(); + let mut rng = Rng::new(11); + let mut b = wild(&c, solo(&c, 1, 5, &[10, 0, 0, 0]), mon(&c, 2, 4, &[10, 0, 0, 0])); + let before = b.party.get(0).unwrap().exp; + assert_eq!(play_out(&mut b, &c, &mut rng), spec::outcome::WIN); + let party = b.take_party(); + assert!(party.get(0).unwrap().exp > before, "no experience awarded"); +} + +#[test] +fn a_super_effective_matchup_is_announced_and_hurts_more() { + let c = fixture(); + let mut rng = Rng::new(13); + // EMBER vs a GRASS foe + let mut b = wild(&c, solo(&c, 1, 20, &[11, 0, 0, 0]), mon(&c, 2, 30, &[10, 0, 0, 0])); + flush(&mut b, &c, &mut rng); + let hp_before = b.foe.mon.hp; + b.choose_action(spec::action::FIGHT, &c, &mut rng); + b.choose_move(0, &c, &mut rng); + assert!(b.messages.iter().any(|m| m.contains("super effective")), "{:?}", b.messages); + assert!(b.foe.mon.hp < hp_before); +} + +#[test] +fn pp_drains_and_a_spent_move_is_refused() { + let c = fixture(); + let mut rng = Rng::new(17); + let mut b = wild(&c, solo(&c, 1, 20, &[10, 0, 0, 0]), mon(&c, 2, 20, &[10, 0, 0, 0])); + flush(&mut b, &c, &mut rng); + let pp = b.player.mon.moves[0].pp; + b.choose_action(spec::action::FIGHT, &c, &mut rng); + b.choose_move(0, &c, &mut rng); + assert_eq!(b.player.mon.moves[0].pp, pp - 1); + + b.player.mon.moves[0].pp = 0; + flush(&mut b, &c, &mut rng); + if b.phase == spec::phase::CHOOSE_ACTION { + b.choose_action(spec::action::FIGHT, &c, &mut rng); + } + b.choose_move(0, &c, &mut rng); + assert!(b.messages.iter().any(|m| m.contains("No PP")), "{:?}", b.messages); +} + +#[test] +fn an_empty_move_slot_is_not_selectable() { + let c = fixture(); + let mut rng = Rng::new(19); + let mut b = wild(&c, solo(&c, 1, 20, &[10, 0, 0, 0]), mon(&c, 2, 20, &[10, 0, 0, 0])); + flush(&mut b, &c, &mut rng); + b.choose_action(spec::action::FIGHT, &c, &mut rng); + let turn = b.turn; + b.choose_move(3, &c, &mut rng); // empty + assert_eq!(b.turn, turn, "an empty slot must not spend the turn"); +} + +#[test] +fn the_faster_creature_moves_first() { + let c = fixture(); + let mut rng = Rng::new(23); + // ROOKIE (base speed 90) vs SPROUT (base speed 20) at the same level. + let mut b = wild(&c, solo(&c, 1, 20, &[10, 0, 0, 0]), mon(&c, 2, 20, &[10, 0, 0, 0])); + flush(&mut b, &c, &mut rng); + b.choose_action(spec::action::FIGHT, &c, &mut rng); + b.choose_move(0, &c, &mut rng); + let first_user = b.messages.iter().find(|m| m.contains(" used ")).cloned().unwrap(); + assert!(first_user.starts_with("ROOKIE"), "{first_user}"); +} + +#[test] +fn paralysis_quarters_speed_and_flips_the_order() { + let c = fixture(); + let mut _rng = Rng::new(29); + let mut b = wild(&c, solo(&c, 1, 20, &[10, 0, 0, 0]), mon(&c, 2, 20, &[10, 0, 0, 0])); + let fast = b.player.speed(); + b.player.mon.status = spec::status::PARALYSIS; + assert_eq!(b.player.speed(), (fast / 4).max(1)); +} + +#[test] +fn running_from_a_wild_battle_can_succeed() { + let c = fixture(); + let mut rng = Rng::new(31); + // A much faster lead always escapes. + let mut b = wild(&c, solo(&c, 1, 30, &[10, 0, 0, 0]), mon(&c, 2, 3, &[10, 0, 0, 0])); + flush(&mut b, &c, &mut rng); + b.choose_action(spec::action::RUN, &c, &mut rng); + flush(&mut b, &c, &mut rng); + assert_eq!(b.outcome, Some(spec::outcome::RAN)); +} + +#[test] +fn running_from_a_trainer_is_impossible() { + let c = fixture(); + let mut rng = Rng::new(37); + let trainer = Trainer { + id: 1, + name_key: key::RIVAL, + ai_class: 1, + reward_base: 10, + party: vec![crate::content::TrainerMon { + species: 2, + level: 5, + flags: 0, + moves: [10, 0, 0, 0], + }], + }; + let mut b = + Battle::trainer(&c, solo(&c, 1, 30, &[10, 0, 0, 0]), 0, &trainer, &mut rng).unwrap(); + flush(&mut b, &c, &mut rng); + b.choose_action(spec::action::RUN, &c, &mut rng); + assert!(b.messages.iter().any(|m| m.contains("Can't escape")), "{:?}", b.messages); + assert_eq!(b.outcome, None); +} + +#[test] +fn a_trainer_sends_out_every_creature_before_losing() { + let c = fixture(); + let mut rng = Rng::new(41); + let trainer = Trainer { + id: 1, + name_key: key::RIVAL, + ai_class: 1, + reward_base: 10, + party: vec![ + crate::content::TrainerMon { species: 2, level: 3, flags: 0, moves: [10, 0, 0, 0] }, + crate::content::TrainerMon { species: 2, level: 3, flags: 0, moves: [10, 0, 0, 0] }, + crate::content::TrainerMon { species: 2, level: 3, flags: 0, moves: [10, 0, 0, 0] }, + ], + }; + let mut b = + Battle::trainer(&c, solo(&c, 1, 40, &[10, 0, 0, 0]), 0, &trainer, &mut rng).unwrap(); + assert_eq!(play_out(&mut b, &c, &mut rng), spec::outcome::WIN); + assert_eq!(b.foe_index, 2, "all three were sent out"); + assert!(b.foe_party.is_empty()); +} + +#[test] +fn a_trainer_with_an_unusable_roster_does_not_start() { + let c = fixture(); + let mut rng = Rng::new(43); + let trainer = Trainer { + id: 2, + name_key: key::RIVAL, + ai_class: 0, + reward_base: 0, + party: vec![crate::content::TrainerMon { + species: 9999, // no such species + level: 5, + flags: 0, + moves: [10, 0, 0, 0], + }], + }; + assert!(Battle::trainer(&c, solo(&c, 1, 5, &[10, 0, 0, 0]), 0, &trainer, &mut rng).is_none()); +} + +#[test] +fn winning_a_trainer_battle_pays_out() { + let c = fixture(); + let mut rng = Rng::new(47); + let trainer = Trainer { + id: 1, + name_key: key::RIVAL, + ai_class: 1, + reward_base: 10, + party: vec![crate::content::TrainerMon { + species: 2, + level: 4, + flags: 0, + moves: [10, 0, 0, 0], + }], + }; + let mut b = + Battle::trainer(&c, solo(&c, 1, 40, &[10, 0, 0, 0]), 0, &trainer, &mut rng).unwrap(); + play_out(&mut b, &c, &mut rng); + assert!(b.reward > 0); + let mut ev = EventQueue::new(); + b.emit_end(&mut ev); + let e = ev.find(spec::event::BATTLE_ENDED).copied().unwrap(); + assert_eq!(e.a, spec::outcome::WIN as u16); + assert_eq!(e.b, 1, "the trainer id rides along"); +} + +#[test] +fn a_faint_forces_a_switch_that_costs_no_turn() { + let c = fixture(); + let mut rng = Rng::new(53); + let party = party_of(vec![ + mon(&c, 2, 2, &[10, 0, 0, 0]), // will faint + mon(&c, 1, 40, &[10, 0, 0, 0]), + ]); + let mut b = wild(&c, party, mon(&c, 1, 30, &[10, 0, 0, 0])); + + // Knock the lead out directly, then let the queue drain. + b.player.mon.hp = 0; + b.messages.push("SPROUT fainted!".to_string()); + b.store_player(); + b.must_switch = true; + flush(&mut b, &c, &mut rng); + assert_eq!(b.phase, spec::phase::CHOOSE_SWITCH); + + let turn = b.turn; + b.choose_switch(1, &c, &mut rng); + assert_eq!(b.turn, turn, "a forced switch is free"); + assert_eq!(b.player.slot, 1); + assert!(!b.must_switch); +} + +#[test] +fn switching_writes_the_outgoing_creature_back_to_the_party() { + let c = fixture(); + let mut rng = Rng::new(59); + let party = party_of(vec![mon(&c, 1, 20, &[10, 0, 0, 0]), mon(&c, 1, 20, &[10, 0, 0, 0])]); + let mut b = wild(&c, party, mon(&c, 2, 5, &[10, 0, 0, 0])); + flush(&mut b, &c, &mut rng); + b.player.mon.damage(11); + let hurt = b.player.mon.hp; + b.choose_switch(1, &c, &mut rng); + assert_eq!(b.party.get(0).unwrap().hp, hurt, "damage persisted into the party"); + assert_eq!(b.player.slot, 1); +} + +#[test] +fn switching_out_clears_volatile_state() { + let c = fixture(); + let mut rng = Rng::new(61); + let party = party_of(vec![mon(&c, 1, 20, &[10, 0, 0, 0]), mon(&c, 1, 20, &[10, 0, 0, 0])]); + let mut b = wild(&c, party, mon(&c, 2, 5, &[10, 0, 0, 0])); + flush(&mut b, &c, &mut rng); + b.player.stages.shift(crate::mon::stats::stat::ATTACK, 4); + b.player.confused = 3; + b.choose_switch(1, &c, &mut rng); + assert_eq!(b.player.stages.get(crate::mon::stats::stat::ATTACK), 0); + assert_eq!(b.player.confused, 0); +} + +#[test] +fn a_ball_can_catch_a_wild_creature() { + let c = fixture(); + let mut rng = Rng::new(67); + let mut b = wild(&c, solo(&c, 1, 30, &[10, 0, 0, 0]), mon(&c, 2, 3, &[10, 0, 0, 0])); + flush(&mut b, &c, &mut rng); + // Weaken it so the second gate is easy, then throw until it sticks. + b.foe.mon.hp = 1; + for _ in 0..40 { + if b.outcome.is_some() { + break; + } + b.choose_item(1, &c, &mut rng); + flush(&mut b, &c, &mut rng); + } + assert_eq!(b.outcome, Some(spec::outcome::CAUGHT)); + assert!(b.caught.is_some()); + assert_eq!(b.caught.as_ref().unwrap().species, 2); +} + +#[test] +fn a_ball_is_useless_against_a_trainer() { + let c = fixture(); + let mut rng = Rng::new(71); + let trainer = Trainer { + id: 1, + name_key: key::RIVAL, + ai_class: 0, + reward_base: 5, + party: vec![crate::content::TrainerMon { + species: 2, + level: 5, + flags: 0, + moves: [10, 0, 0, 0], + }], + }; + let mut b = + Battle::trainer(&c, solo(&c, 1, 30, &[10, 0, 0, 0]), 0, &trainer, &mut rng).unwrap(); + flush(&mut b, &c, &mut rng); + b.choose_item(1, &c, &mut rng); + assert!(b.messages.iter().any(|m| m.contains("blocked")), "{:?}", b.messages); + assert_eq!(b.outcome, None); +} + +#[test] +fn a_potion_heals_and_spends_the_turn() { + let c = fixture(); + let mut rng = Rng::new(73); + let mut b = wild(&c, solo(&c, 1, 30, &[10, 0, 0, 0]), mon(&c, 2, 3, &[10, 0, 0, 0])); + flush(&mut b, &c, &mut rng); + b.player.mon.damage(30); + let hurt = b.player.mon.hp; + let turn = b.turn; + b.choose_item(2, &c, &mut rng); + assert!(b.player.mon.hp > hurt); + assert_eq!(b.turn, turn + 1, "using an item costs the turn"); +} + +#[test] +fn an_unknown_item_is_ignored() { + let c = fixture(); + let mut rng = Rng::new(79); + let mut b = wild(&c, solo(&c, 1, 30, &[10, 0, 0, 0]), mon(&c, 2, 3, &[10, 0, 0, 0])); + flush(&mut b, &c, &mut rng); + let msgs = b.messages.len(); + b.choose_item(9999, &c, &mut rng); + assert_eq!(b.messages.len(), msgs); +} + +#[test] +fn poison_ticks_at_the_end_of_every_turn() { + let c = fixture(); + let mut rng = Rng::new(83); + let mut b = wild(&c, solo(&c, 1, 30, &[10, 0, 0, 0]), mon(&c, 2, 30, &[10, 0, 0, 0])); + flush(&mut b, &c, &mut rng); + b.player.mon.status = spec::status::POISON; + let before = b.player.mon.hp; + b.choose_action(spec::action::FIGHT, &c, &mut rng); + b.choose_move(0, &c, &mut rng); + assert!(b.player.mon.hp < before); + assert!(b.messages.iter().any(|m| m.contains("hurt by poison")), "{:?}", b.messages); +} + +#[test] +fn a_sleeping_creature_cannot_act_until_it_wakes() { + let c = fixture(); + let mut rng = Rng::new(89); + // A different species on the far side, so the messages name only one ROOKIE. + let mut b = wild(&c, solo(&c, 1, 30, &[10, 0, 0, 0]), mon(&c, 2, 30, &[10, 0, 0, 0])); + flush(&mut b, &c, &mut rng); + b.player.mon.status = spec::status::SLEEP; + b.player.mon.sleep = 3; + b.choose_action(spec::action::FIGHT, &c, &mut rng); + b.choose_move(0, &c, &mut rng); + assert!(b.messages.iter().any(|m| m.contains("fast asleep")), "{:?}", b.messages); + assert!(!b.messages.iter().any(|m| m.starts_with("ROOKIE used")), "{:?}", b.messages); +} + +#[test] +fn a_whole_battle_is_reproducible_from_its_seed() { + let c = fixture(); + let transcript = |seed: u64| { + let mut rng = Rng::new(seed); + let mut b = wild(&c, solo(&c, 1, 12, &[10, 11, 0, 0]), mon(&c, 2, 12, &[10, 0, 0, 0])); + let mut log: Vec = Vec::new(); + for _ in 0..400 { + while let Some(m) = b.messages.first().cloned() { + log.push(m); + b.msg_hold = 0; + b.advance(&c, &mut rng); + } + match b.phase { + spec::phase::CHOOSE_ACTION => { + b.choose_action(spec::action::FIGHT, &c, &mut rng); + b.choose_move(0, &c, &mut rng); + } + spec::phase::CHOOSE_MOVE => b.choose_move(0, &c, &mut rng), + spec::phase::CHOOSE_SWITCH => b.player_wiped(&c), + spec::phase::ENDED => break, + _ => {} + } + } + log + }; + let a = transcript(0x1234); + let b = transcript(0x1234); + assert_eq!(a, b, "same seed must produce the same transcript"); + assert!(a.len() > 5); + assert_ne!(a, transcript(0x5678), "a different seed should differ"); +} + +#[test] +fn a_battle_always_terminates_across_many_seeds() { + // The fuzz that matters: no seed may leave the state machine spinning. + let c = fixture(); + for seed in 1..60u64 { + let mut rng = Rng::new(seed); + let party = party_of(vec![ + mon(&c, 1, 10, &[10, 11, 12, 0]), + mon(&c, 2, 10, &[10, 0, 0, 0]), + ]); + let mut b = wild(&c, party, mon(&c, 2, 10, &[10, 12, 0, 0])); + let outcome = play_out(&mut b, &c, &mut rng); + assert!( + matches!( + outcome, + spec::outcome::WIN | spec::outcome::LOSS | spec::outcome::RAN | spec::outcome::CAUGHT + ), + "seed {seed} ended as {outcome}" + ); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/content.rs b/engine/pocketmon/crates/pocketmon-core/src/content.rs new file mode 100644 index 00000000..ae840f00 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/content.rs @@ -0,0 +1,1207 @@ +//! The content registries and the MONPAK reader. +//! +//! Everything the game *is* — creatures, moves, types, items, maps, tilesets, +//! art, text, scripts, music — arrives here, either cooked in one MONPAK blob +//! (`apps/mon/cook.ts`, the fast path) or one record at a time through the +//! `defineX` ops (the mod path). The core ships zero content: docs/MON.md §1. +//! +//! ## Parsing discipline +//! +//! This is the only module that reads untrusted bytes, and it runs on a PSP +//! where a panic aborts the EBOOT with a black screen. So: every read is +//! bounds-checked through [`Reader`], every length is validated against what +//! remains, and a malformed pak makes `load` return `false` with the +//! registries left exactly as they were. There is no `unwrap`, no slice +//! indexing, and no arithmetic that can overflow a `usize` on a 32-bit target. + +use alloc::collections::BTreeMap; +use alloc::string::String; +use alloc::vec; +use alloc::vec::Vec; + +use crate::spec; + +// --------------------------------------------------------------------------- +// Reader — checked little-endian access +// --------------------------------------------------------------------------- + +/// A cursor over a byte slice. Every accessor returns `Option`; a `None` +/// anywhere aborts the load. +pub struct Reader<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + pub fn new(bytes: &'a [u8]) -> Self { + Reader { bytes, pos: 0 } + } + + pub fn at(bytes: &'a [u8], pos: usize) -> Option { + if pos > bytes.len() { + return None; + } + Some(Reader { bytes, pos }) + } + + pub fn remaining(&self) -> usize { + self.bytes.len().saturating_sub(self.pos) + } + + pub fn pos(&self) -> usize { + self.pos + } + + pub fn skip(&mut self, n: usize) -> Option<()> { + if n > self.remaining() { + return None; + } + self.pos += n; + Some(()) + } + + pub fn take(&mut self, n: usize) -> Option<&'a [u8]> { + if n > self.remaining() { + return None; + } + let out = &self.bytes[self.pos..self.pos + n]; + self.pos += n; + Some(out) + } + + pub fn u8(&mut self) -> Option { + Some(self.take(1)?[0]) + } + + pub fn i8(&mut self) -> Option { + Some(self.u8()? as i8) + } + + pub fn u16(&mut self) -> Option { + let b = self.take(2)?; + Some(u16::from_le_bytes([b[0], b[1]])) + } + + pub fn i16(&mut self) -> Option { + Some(self.u16()? as i16) + } + + pub fn u32(&mut self) -> Option { + let b = self.take(4)?; + Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } +} + +/// Read a u16 at an absolute offset without moving a cursor. +fn peek_u16(bytes: &[u8], off: usize) -> Option { + let b = bytes.get(off..off.checked_add(2)?)?; + Some(u16::from_le_bytes([b[0], b[1]])) +} + +// --------------------------------------------------------------------------- +// Records +// --------------------------------------------------------------------------- + +/// A creature species. Layout pinned by `spec::SPECIES_SIZE`. +#[derive(Clone, Debug, Default)] +pub struct Species { + pub id: u16, + pub base_hp: u8, + pub base_atk: u8, + pub base_def: u8, + pub base_spd: u8, + pub base_spc: u8, + pub type1: u8, + pub type2: u8, + pub catch_rate: u8, + pub base_exp: u16, + pub growth: u8, + pub front_tile: u8, + pub back_tile: u8, + pub icon_tile: u8, + pub name_key: u16, + pub dex_key: u16, + pub evolve_kind: u8, + pub evolve_param: u16, + pub evolve_into: u16, + /// Slice of the shared learnset pool: `[offset, offset + count)`. + pub learn_offset: u16, + pub learn_count: u8, +} + +/// How a species evolves. +pub mod evolve { + pub const NONE: u8 = 0; + pub const LEVEL: u8 = 1; + pub const ITEM: u8 = 2; + pub const TRADE: u8 = 3; +} + +/// One learnset entry: the move learned on reaching `level`. +#[derive(Clone, Copy, Debug, Default)] +pub struct Learn { + pub level: u16, + pub move_id: u16, +} + +/// A move. Layout pinned by `spec::MOVE_SIZE`. +#[derive(Clone, Debug, Default)] +pub struct Move { + pub id: u16, + pub kind: u8, + pub power: u8, + pub accuracy: u8, + pub pp: u8, + pub category: u8, + pub effect: u8, + pub effect_chance: u8, + pub flags: u8, + pub name_key: u16, + pub desc_key: u16, + pub anim_id: u16, +} + +impl Move { + pub fn high_crit(&self) -> bool { + self.flags & spec::MOVE_FLAG_HIGH_CRIT != 0 + } +} + +/// An elemental type: its damage category and display name. +#[derive(Clone, Copy, Debug, Default)] +pub struct TypeDef { + pub category: u8, + pub name_key: u16, +} + +/// One row of the effectiveness table, multiplier in x10 fixed point. +#[derive(Clone, Copy, Debug, Default)] +pub struct Matchup { + pub attacker: u8, + pub defender: u8, + pub multiplier: u16, +} + +/// A bag item. Layout pinned by `spec::ITEM_SIZE`. +#[derive(Clone, Debug, Default)] +pub struct Item { + pub id: u16, + pub name_key: u16, + pub desc_key: u16, + pub kind: u8, + pub param: u8, + pub price: u16, +} + +/// A tileset: the block -> tile expansion plus the per-tile behavior table +/// that the bottom-left-tile collision rule reads. +#[derive(Clone, Debug)] +pub struct Tileset { + /// `blocks[b][ty * 4 + tx]` = tile id. + pub blocks: Vec<[u8; spec::TILESET_BLOCK_SIZE]>, + /// `behavior[tile_id]` = one of `spec::cell::*`. + pub behavior: [u8; spec::TILE_BEHAVIOR_BYTES], +} + +impl Default for Tileset { + fn default() -> Self { + // Default-deny: an unpopulated tileset is solid everywhere, so a + // content bug strands the player instead of dropping them through the + // world (the same fail-closed rule map.rs applies to missing data). + Tileset { + blocks: Vec::new(), + behavior: [spec::cell::WALL; spec::TILE_BEHAVIOR_BYTES], + } + } +} + +impl Tileset { + /// The tile id at (tx, ty) within block `id`, or 0 for an unknown block. + pub fn block_tile(&self, id: u8, tx: usize, ty: usize) -> u8 { + match self.blocks.get(id as usize) { + Some(b) => b[(ty % spec::BLOCK_TILES) * spec::BLOCK_TILES + (tx % spec::BLOCK_TILES)], + None => 0, + } + } + + /// The behavior of a tile id. + pub fn behavior_of(&self, tile: u8) -> u8 { + self.behavior[tile as usize] + } +} + +/// A warp pad: stepping on it sends the player to `dest_map`'s `dest_warp`. +#[derive(Clone, Copy, Debug, Default)] +pub struct Warp { + pub x: u8, + pub y: u8, + pub dest_map: u16, + pub dest_warp: u8, + pub dir: u8, +} + +/// A readable sign at a cell. +#[derive(Clone, Copy, Debug, Default)] +pub struct Sign { + pub x: u8, + pub y: u8, + pub text_key: u16, +} + +/// An NPC/object placement. Layout pinned by `spec::ACTOR_SIZE`. +#[derive(Clone, Copy, Debug, Default)] +pub struct ActorDef { + pub x: u8, + pub y: u8, + pub dir: u8, + pub behavior: u8, + pub sprite: u8, + pub flags: u8, + pub text_key: u16, + pub trainer_id: i16, + /// Hidden while this flag is set; `0xffff` = always visible. + pub flag_gate: u16, +} + +/// One wild-encounter slot. +#[derive(Clone, Copy, Debug, Default)] +pub struct EncounterSlot { + pub species: u16, + pub level: u8, +} + +/// A map: the block layout plus everything placed on it. +#[derive(Clone, Debug, Default)] +pub struct MapDef { + pub id: u16, + pub width: u8, + pub height: u8, + pub tileset: u8, + pub border_block: u8, + pub flags: u8, + pub encounter_rate: u8, + pub name_key: u16, + pub music_id: u16, + /// `blocks[by * width + bx]`. + pub blocks: Vec, + pub warps: Vec, + pub signs: Vec, + pub actors: Vec, + pub slots: Vec, + /// Connected map ids per direction, `-1` for none, in `spec::dir` order + /// remapped to N/S/W/E as stored. + pub conn: [i16; 4], + /// Cell offset of each connection's alignment. + pub conn_off: [i16; 4], +} + +/// Index into `MapDef::conn` / `conn_off`. +pub mod conn { + pub const NORTH: usize = 0; + pub const SOUTH: usize = 1; + pub const WEST: usize = 2; + pub const EAST: usize = 3; +} + +impl MapDef { + pub fn width_cells(&self) -> i32 { + self.width as i32 * spec::BLOCK_CELLS + } + + pub fn height_cells(&self) -> i32 { + self.height as i32 * spec::BLOCK_CELLS + } + + pub fn indoor(&self) -> bool { + self.flags & spec::MAP_FLAG_INDOOR != 0 + } + + /// The block id at block coordinates, falling back to the border block + /// outside the map — the "border ring" the original engine draws. + pub fn block_at(&self, bx: i32, by: i32) -> u8 { + if bx < 0 || by < 0 || bx >= self.width as i32 || by >= self.height as i32 { + return self.border_block; + } + let idx = by as usize * self.width as usize + bx as usize; + *self.blocks.get(idx).unwrap_or(&self.border_block) + } + + /// The warp at a cell, if any. + pub fn warp_at(&self, cx: i32, cy: i32) -> Option<(usize, &Warp)> { + self.warps + .iter() + .enumerate() + .find(|(_, w)| w.x as i32 == cx && w.y as i32 == cy) + } + + /// The sign at a cell, if any. + pub fn sign_at(&self, cx: i32, cy: i32) -> Option<&Sign> { + self.signs.iter().find(|s| s.x as i32 == cx && s.y as i32 == cy) + } +} + +/// One trainer's roster entry. +#[derive(Clone, Copy, Debug, Default)] +pub struct TrainerMon { + pub species: u16, + pub level: u8, + pub flags: u8, + pub moves: [u16; spec::MOVES_MAX], +} + +/// A trainer: who they are, what they field, what beating them pays. +#[derive(Clone, Debug, Default)] +pub struct Trainer { + pub id: u16, + pub name_key: u16, + pub ai_class: u8, + pub reward_base: u16, + pub party: Vec, +} + +/// A font glyph in the atlas. +#[derive(Clone, Copy, Debug, Default)] +pub struct Glyph { + pub codepoint: u32, + pub u: u16, + pub v: u16, + pub w: u8, + pub h: u8, + pub advance: u8, +} + +/// A CLUT8 atlas page. +#[derive(Clone, Debug, Default)] +pub struct AtlasPage { + pub w: u16, + pub h: u16, + pub pixels: Vec, +} + +// --------------------------------------------------------------------------- +// Content +// --------------------------------------------------------------------------- + +/// Every registry, keyed for O(1) or O(log n) lookup by the ids records use. +#[derive(Clone, Debug, Default)] +pub struct Content { + pub palette: Vec, + pub pages: Vec, + pub tilesets: Vec, + pub maps: BTreeMap, + pub species: BTreeMap, + pub learn_pool: Vec, + pub moves: BTreeMap, + pub types: Vec, + pub matchups: Vec, + pub items: BTreeMap, + pub trainers: BTreeMap, + pub scripts: BTreeMap>, + pub strings: Vec, + pub glyphs: Vec, + pub font_line_height: u8, + pub font_page: u8, + pub audio: Vec>, + pub song_count: u16, + /// Blocks a script replaced at runtime (the `replace_block` verb), keyed + /// by `(map_id << 16) | block_index`. + /// + /// An overlay rather than a mutation of `maps`: the cooked content stays + /// pristine (so a reload is a reload), and the overlay is small enough to + /// go straight into the save file, which is what makes a door a script + /// opened still open after loading. + pub block_overrides: BTreeMap, +} + +impl Content { + pub fn new() -> Self { + Content { + palette: vec![0; spec::PALETTE_ENTRIES], + ..Default::default() + } + } + + /// A string by key id; empty for an unknown key so callers never branch. + pub fn string(&self, key: u16) -> &str { + self.strings.get(key as usize).map(String::as_str).unwrap_or("") + } + + pub fn species_of(&self, id: u16) -> Option<&Species> { + self.species.get(&id) + } + + pub fn move_of(&self, id: u16) -> Option<&Move> { + self.moves.get(&id) + } + + pub fn map_of(&self, id: u16) -> Option<&MapDef> { + self.maps.get(&id) + } + + pub fn tileset_of(&self, id: u8) -> Option<&Tileset> { + self.tilesets.get(id as usize) + } + + /// The key a block override is stored under. + fn override_key(map: u16, index: u16) -> u32 { + (map as u32) << 16 | index as u32 + } + + /// Replace a block at runtime. Out-of-range coordinates are ignored. + pub fn set_block(&mut self, map: u16, bx: i32, by: i32, block: u8) { + let Some(m) = self.maps.get(&map) else { return }; + if bx < 0 || by < 0 || bx >= m.width as i32 || by >= m.height as i32 { + return; + } + let index = by as u32 * m.width as u32 + bx as u32; + if index > u16::MAX as u32 { + return; + } + self.block_overrides + .insert(Self::override_key(map, index as u16), block); + } + + /// The block id at block coordinates, honouring runtime overrides. + pub fn block_at(&self, m: &MapDef, bx: i32, by: i32) -> u8 { + if bx >= 0 && by >= 0 && bx < m.width as i32 && by < m.height as i32 { + let index = by as u32 * m.width as u32 + bx as u32; + if index <= u16::MAX as u32 { + if let Some(&b) = self.block_overrides.get(&Self::override_key(m.id, index as u16)) + { + return b; + } + } + } + m.block_at(bx, by) + } + + /// The learnset slice of a species. + pub fn learnset(&self, s: &Species) -> &[Learn] { + let start = s.learn_offset as usize; + let end = start.saturating_add(s.learn_count as usize); + self.learn_pool.get(start..end).unwrap_or(&[]) + } + + /// The damage category of a type, defaulting to physical for unknown ids + /// (the upstream engine's fallback, minus the log spam). + pub fn type_category(&self, type_id: u8) -> u8 { + self.types + .get(type_id as usize) + .map(|t| t.category) + .unwrap_or(spec::category::PHYSICAL) + } + + /// Every matchup row that applies, in table order. + /// + /// Order matters: the damage step applies each row to the running damage + /// separately with its own floor, so 0.5 x 0.5 lands on + /// `floor(floor(d/2)/2)` rather than `d/4` (upstream `TypeChart.rows`). + pub fn matchup_rows(&self, move_type: u8, def_types: (u8, u8), out: &mut Vec) { + out.clear(); + for m in &self.matchups { + if m.attacker != move_type { + continue; + } + if m.defender == def_types.0 || (def_types.1 != def_types.0 && m.defender == def_types.1) + { + out.push(m.multiplier); + } + } + } + + /// The combined x10 effectiveness multiplier (for messages and AI). + pub fn effectiveness(&self, move_type: u8, def_types: (u8, u8)) -> u32 { + let mut mult = spec::TYPE_SCALE; + for m in &self.matchups { + if m.attacker != move_type { + continue; + } + if m.defender == def_types.0 || (def_types.1 != def_types.0 && m.defender == def_types.1) + { + mult = mult * m.multiplier as u32 / spec::TYPE_SCALE; + } + } + mult + } + + // ----------------------------------------------------------------------- + // MONPAK + // ----------------------------------------------------------------------- + + /// Load a cooked MONPAK. Returns false and leaves the registries untouched + /// if anything about the blob does not check out. + pub fn load_pak(&mut self, blob: &[u8]) -> bool { + let mut staged = Content::new(); + if staged.parse_pak(blob).is_none() { + return false; + } + *self = staged; + true + } + + /// Merge a MONPAK on top of the current content (the mod path: later paks + /// override earlier records by id, and sections they omit are left alone). + pub fn merge_pak(&mut self, blob: &[u8]) -> bool { + let mut staged = Content::new(); + if staged.parse_pak(blob).is_none() { + return false; + } + if !staged.palette.iter().all(|&c| c == 0) { + self.palette = staged.palette; + } + if !staged.pages.is_empty() { + self.pages = staged.pages; + } + if !staged.tilesets.is_empty() { + self.tilesets = staged.tilesets; + } + if !staged.types.is_empty() { + self.types = staged.types; + self.matchups = staged.matchups; + } + if !staged.glyphs.is_empty() { + self.glyphs = staged.glyphs; + self.font_line_height = staged.font_line_height; + self.font_page = staged.font_page; + } + if !staged.learn_pool.is_empty() { + // Learnset offsets are relative to the pak that declared them, so a + // merged pak's species must carry their pool along with them. + let base = self.learn_pool.len() as u16; + self.learn_pool.extend_from_slice(&staged.learn_pool); + for (id, mut sp) in staged.species { + sp.learn_offset = sp.learn_offset.saturating_add(base); + self.species.insert(id, sp); + } + } else { + self.species.extend(staged.species); + } + self.maps.extend(staged.maps); + self.moves.extend(staged.moves); + self.items.extend(staged.items); + self.trainers.extend(staged.trainers); + self.scripts.extend(staged.scripts); + if !staged.strings.is_empty() { + self.strings = staged.strings; + } + if !staged.audio.is_empty() { + self.audio = staged.audio; + self.song_count = staged.song_count; + } + true + } + + fn parse_pak(&mut self, blob: &[u8]) -> Option<()> { + let mut r = Reader::new(blob); + if r.u32()? != spec::monpak::MAGIC { + return None; + } + if r.u16()? != spec::monpak::VERSION { + return None; + } + let section_count = r.u16()? as usize; + let total = r.u32()? as usize; + let _reserved = r.u32()?; + if total != blob.len() { + return None; + } + // The section table must fit inside the blob before any payload. + let table_bytes = section_count.checked_mul(spec::monpak::ENTRY_SIZE)?; + if spec::monpak::HEADER_SIZE.checked_add(table_bytes)? > blob.len() { + return None; + } + + for _ in 0..section_count { + let tag = r.u32()?; + let offset = r.u32()? as usize; + let length = r.u32()? as usize; + let count = r.u32()? as usize; + let end = offset.checked_add(length)?; + if end > blob.len() { + return None; + } + let payload = blob.get(offset..end)?; + self.parse_section(tag, payload, count)?; + } + Some(()) + } + + fn parse_section(&mut self, tag: u32, payload: &[u8], count: usize) -> Option<()> { + use spec::monpak::*; + match tag { + TAG_PALETTE => self.parse_palette(payload), + TAG_ATLAS => self.parse_atlas(payload), + TAG_TILESET => self.parse_tilesets(payload), + TAG_MAPS => self.parse_maps(payload), + TAG_SPECIES => self.parse_species(payload), + TAG_MOVES => self.parse_moves(payload), + TAG_TYPES => self.parse_types(payload), + TAG_ITEMS => self.parse_items(payload), + TAG_TRAINERS => self.parse_trainers(payload), + TAG_SCRIPTS => self.parse_scripts(payload), + TAG_TEXT => self.parse_text(payload), + TAG_FONT => self.parse_font(payload), + TAG_AUDIO => self.parse_audio(payload), + // Unknown sections are skipped, not fatal: that is what makes the + // format forward-compatible for a newer cooker's extra data. + _ => { + let _ = count; + Some(()) + } + } + } + + fn parse_palette(&mut self, payload: &[u8]) -> Option<()> { + if payload.len() < spec::PALETTE_BYTES { + return None; + } + let mut r = Reader::new(payload); + self.palette.clear(); + for _ in 0..spec::PALETTE_ENTRIES { + self.palette.push(r.u32()?); + } + Some(()) + } + + fn parse_atlas(&mut self, payload: &[u8]) -> Option<()> { + let mut r = Reader::new(payload); + let pages = r.u16()? as usize; + let _reserved = r.u16()?; + if pages > spec::PAGE_MAX { + return None; + } + for _ in 0..pages { + let w = r.u16()?; + let h = r.u16()?; + let len = r.u32()? as usize; + if len != (w as usize).checked_mul(h as usize)? { + return None; + } + let pixels = r.take(len)?.to_vec(); + // Pages are padded to a 4-byte boundary so the next header stays aligned. + r.skip((4 - (len % 4)) % 4)?; + self.pages.push(AtlasPage { w, h, pixels }); + } + Some(()) + } + + fn parse_tilesets(&mut self, payload: &[u8]) -> Option<()> { + let mut r = Reader::new(payload); + let count = r.u16()? as usize; + let _reserved = r.u16()?; + for _ in 0..count { + let blocks = r.u16()? as usize; + let _reserved = r.u16()?; + let mut ts = Tileset { + blocks: Vec::with_capacity(blocks), + behavior: [spec::cell::WALL; spec::TILE_BEHAVIOR_BYTES], + }; + for _ in 0..blocks { + let raw = r.take(spec::TILESET_BLOCK_SIZE)?; + let mut b = [0u8; spec::TILESET_BLOCK_SIZE]; + b.copy_from_slice(raw); + ts.blocks.push(b); + } + let beh = r.take(spec::TILE_BEHAVIOR_BYTES)?; + ts.behavior.copy_from_slice(beh); + self.tilesets.push(ts); + } + Some(()) + } + + fn parse_maps(&mut self, payload: &[u8]) -> Option<()> { + let mut r = Reader::new(payload); + let count = r.u16()? as usize; + let _reserved = r.u16()?; + let mut offsets = Vec::with_capacity(count); + for _ in 0..count { + offsets.push(r.u32()? as usize); + } + for off in offsets { + let mut m = Reader::at(payload, off)?; + let id = m.u16()?; + let width = m.u8()?; + let height = m.u8()?; + let tileset = m.u8()?; + let border_block = m.u8()?; + let flags = m.u8()?; + let encounter_rate = m.u8()?; + let name_key = m.u16()?; + let music_id = m.u16()?; + let warp_count = m.u8()? as usize; + let sign_count = m.u8()? as usize; + let actor_count = m.u8()? as usize; + let slot_count = m.u8()? as usize; + let mut conn = [0i16; 4]; + for c in conn.iter_mut() { + *c = m.i16()?; + } + let mut conn_off = [0i16; 4]; + for c in conn_off.iter_mut() { + *c = m.i16()?; + } + if actor_count > spec::ACTORS_MAX { + return None; + } + + let block_bytes = (width as usize).checked_mul(height as usize)?; + let blocks = m.take(block_bytes)?.to_vec(); + + let mut warps = Vec::with_capacity(warp_count); + for _ in 0..warp_count { + let x = m.u8()?; + let y = m.u8()?; + let dest_map = m.u16()?; + let dest_warp = m.u8()?; + let dir = m.u8()?; + let _reserved = m.u16()?; + warps.push(Warp { x, y, dest_map, dest_warp, dir }); + } + let mut signs = Vec::with_capacity(sign_count); + for _ in 0..sign_count { + let x = m.u8()?; + let y = m.u8()?; + let text_key = m.u16()?; + signs.push(Sign { x, y, text_key }); + } + let mut actors = Vec::with_capacity(actor_count); + for _ in 0..actor_count { + let x = m.u8()?; + let y = m.u8()?; + let dir = m.u8()?; + let behavior = m.u8()?; + let sprite = m.u8()?; + let a_flags = m.u8()?; + let text_key = m.u16()?; + let trainer_id = m.i16()?; + let flag_gate = m.u16()?; + actors.push(ActorDef { + x, + y, + dir, + behavior, + sprite, + flags: a_flags, + text_key, + trainer_id, + flag_gate, + }); + } + let mut slots = Vec::with_capacity(slot_count); + for _ in 0..slot_count { + let species = m.u16()?; + let level = m.u8()?; + let _reserved = m.u8()?; + slots.push(EncounterSlot { species, level }); + } + + self.maps.insert( + id, + MapDef { + id, + width, + height, + tileset, + border_block, + flags, + encounter_rate, + name_key, + music_id, + blocks, + warps, + signs, + actors, + slots, + conn, + conn_off, + }, + ); + } + Some(()) + } + + fn parse_species(&mut self, payload: &[u8]) -> Option<()> { + let mut r = Reader::new(payload); + let count = r.u16()? as usize; + let learn_count = r.u16()? as usize; + for _ in 0..count { + let start = r.pos(); + let id = r.u16()?; + let base_hp = r.u8()?; + let base_atk = r.u8()?; + let base_def = r.u8()?; + let base_spd = r.u8()?; + let base_spc = r.u8()?; + let type1 = r.u8()?; + let type2 = r.u8()?; + let catch_rate = r.u8()?; + let base_exp = r.u16()?; + let growth = r.u8()?; + let front_tile = r.u8()?; + let back_tile = r.u8()?; + let icon_tile = r.u8()?; + let name_key = r.u16()?; + let dex_key = r.u16()?; + let l_count = r.u8()?; + let evolve_kind = r.u8()?; + let evolve_param = r.u16()?; + let evolve_into = r.u16()?; + let learn_offset = r.u16()?; + let _reserved = r.u32()?; + // Records are fixed-size; re-anchor so a spec bump that adds a + // field cannot desync the whole table. + debug_assert_eq!(r.pos() - start, spec::SPECIES_SIZE); + self.species.insert( + id, + Species { + id, + base_hp, + base_atk, + base_def, + base_spd, + base_spc, + type1, + type2, + catch_rate, + base_exp, + growth, + front_tile, + back_tile, + icon_tile, + name_key, + dex_key, + evolve_kind, + evolve_param, + evolve_into, + learn_offset, + learn_count: l_count, + }, + ); + } + for _ in 0..learn_count { + let level = r.u16()?; + let move_id = r.u16()?; + self.learn_pool.push(Learn { level, move_id }); + } + Some(()) + } + + fn parse_moves(&mut self, payload: &[u8]) -> Option<()> { + let mut r = Reader::new(payload); + let count = r.u16()? as usize; + let _reserved = r.u16()?; + for _ in 0..count { + let id = r.u16()?; + let kind = r.u8()?; + let power = r.u8()?; + let accuracy = r.u8()?; + let pp = r.u8()?; + let category = r.u8()?; + let effect = r.u8()?; + let effect_chance = r.u8()?; + let flags = r.u8()?; + let name_key = r.u16()?; + let desc_key = r.u16()?; + let anim_id = r.u16()?; + self.moves.insert( + id, + Move { + id, + kind, + power, + accuracy, + pp, + category, + effect, + effect_chance, + flags, + name_key, + desc_key, + anim_id, + }, + ); + } + Some(()) + } + + fn parse_types(&mut self, payload: &[u8]) -> Option<()> { + let mut r = Reader::new(payload); + let type_count = r.u16()? as usize; + let matchup_count = r.u16()? as usize; + for _ in 0..type_count { + let category = r.u8()?; + let _reserved = r.u8()?; + let name_key = r.u16()?; + self.types.push(TypeDef { category, name_key }); + } + for _ in 0..matchup_count { + let attacker = r.u8()?; + let defender = r.u8()?; + let multiplier = r.u16()?; + self.matchups.push(Matchup { attacker, defender, multiplier }); + } + Some(()) + } + + fn parse_items(&mut self, payload: &[u8]) -> Option<()> { + let mut r = Reader::new(payload); + let count = r.u16()? as usize; + let _reserved = r.u16()?; + for _ in 0..count { + let id = r.u16()?; + let name_key = r.u16()?; + let desc_key = r.u16()?; + let kind = r.u8()?; + let param = r.u8()?; + let price = r.u16()?; + let _reserved = r.u16()?; + self.items.insert(id, Item { id, name_key, desc_key, kind, param, price }); + } + Some(()) + } + + fn parse_trainers(&mut self, payload: &[u8]) -> Option<()> { + let mut r = Reader::new(payload); + let count = r.u16()? as usize; + let _reserved = r.u16()?; + let mut offsets = Vec::with_capacity(count); + for _ in 0..count { + offsets.push(r.u32()? as usize); + } + for off in offsets { + let mut t = Reader::at(payload, off)?; + let id = t.u16()?; + let name_key = t.u16()?; + let ai_class = t.u8()?; + let party_count = t.u8()? as usize; + let reward_base = t.u16()?; + if party_count > spec::TRAINER_PARTY_MAX { + return None; + } + let mut party = Vec::with_capacity(party_count); + for _ in 0..party_count { + let species = t.u16()?; + let level = t.u8()?; + let flags = t.u8()?; + let mut moves = [0u16; spec::MOVES_MAX]; + for m in moves.iter_mut() { + *m = t.u16()?; + } + party.push(TrainerMon { species, level, flags, moves }); + } + self.trainers + .insert(id, Trainer { id, name_key, ai_class, reward_base, party }); + } + Some(()) + } + + fn parse_scripts(&mut self, payload: &[u8]) -> Option<()> { + let mut r = Reader::new(payload); + let count = r.u16()? as usize; + let _reserved = r.u16()?; + let mut dir = Vec::with_capacity(count); + for _ in 0..count { + let name_key = r.u16()?; + let _reserved = r.u16()?; + let offset = r.u32()? as usize; + let length = r.u32()? as usize; + dir.push((name_key, offset, length)); + } + for (name_key, offset, length) in dir { + let end = offset.checked_add(length)?; + let body = payload.get(offset..end)?; + // Reject a script whose header lies about its own version now, + // rather than mid-playthrough when an NPC is talked to. + if length < spec::SCRIPT_HEADER_SIZE + || peek_u16(body, 0)? != spec::SCRIPT_VERSION + { + return None; + } + self.scripts.insert(name_key, body.to_vec()); + } + Some(()) + } + + fn parse_text(&mut self, payload: &[u8]) -> Option<()> { + let mut r = Reader::new(payload); + let count = r.u16()? as usize; + let _reserved = r.u16()?; + let mut dir = Vec::with_capacity(count); + for _ in 0..count { + let offset = r.u32()? as usize; + let length = r.u32()? as usize; + dir.push((offset, length)); + } + for (offset, length) in dir { + let end = offset.checked_add(length)?; + let bytes = payload.get(offset..end)?; + // Invalid UTF-8 becomes an empty string rather than failing the + // whole load: one bad line should not cost the player the game. + let s = core::str::from_utf8(bytes).unwrap_or(""); + self.strings.push(String::from(s)); + } + Some(()) + } + + fn parse_font(&mut self, payload: &[u8]) -> Option<()> { + let mut r = Reader::new(payload); + let count = r.u16()? as usize; + self.font_line_height = r.u8()?; + self.font_page = r.u8()?; + for _ in 0..count { + let codepoint = r.u32()?; + let u = r.u16()?; + let v = r.u16()?; + let w = r.u8()?; + let h = r.u8()?; + let advance = r.u8()?; + let _reserved = r.u8()?; + self.glyphs.push(Glyph { codepoint, u, v, w, h, advance }); + } + // Sorted so glyph lookup is a binary search — text draw is the hottest + // non-tile loop in the core (a full textbox is ~120 glyphs a frame). + self.glyphs.sort_unstable_by_key(|g| g.codepoint); + Some(()) + } + + fn parse_audio(&mut self, payload: &[u8]) -> Option<()> { + let mut r = Reader::new(payload); + let song_count = r.u16()?; + let sfx_count = r.u16()?; + let total = song_count as usize + sfx_count as usize; + let mut offsets = Vec::with_capacity(total + 1); + for _ in 0..=total { + offsets.push(r.u32()? as usize); + } + for w in offsets.windows(2) { + let (start, end) = (w[0], w[1]); + if end < start || end > payload.len() { + return None; + } + self.audio.push(payload.get(start..end)?.to_vec()); + } + self.song_count = song_count; + Some(()) + } + + /// Binary-search a glyph by codepoint. + pub fn glyph(&self, codepoint: u32) -> Option<&Glyph> { + let idx = self + .glyphs + .binary_search_by_key(&codepoint, |g| g.codepoint) + .ok()?; + self.glyphs.get(idx) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reader_never_reads_past_the_end() { + let mut r = Reader::new(&[1, 2, 3]); + assert_eq!(r.u16(), Some(0x0201)); + assert_eq!(r.u16(), None, "a straddling read must fail, not wrap"); + assert_eq!(r.u8(), Some(3)); + assert_eq!(r.u8(), None); + assert_eq!(r.take(1), None); + } + + #[test] + fn reader_at_rejects_out_of_range_anchors() { + let bytes = [0u8; 4]; + assert!(Reader::at(&bytes, 4).is_some(), "the end is a valid cursor"); + assert!(Reader::at(&bytes, 5).is_none()); + } + + #[test] + fn truncated_pak_is_rejected_without_touching_content() { + let mut c = Content::new(); + c.strings.push(String::from("keep me")); + assert!(!c.load_pak(&[])); + assert!(!c.load_pak(&[0x4d, 0x4f, 0x4e, 0x50])); + // wrong magic + assert!(!c.load_pak(&[0, 0, 0, 0, 1, 0, 0, 0, 8, 0, 0, 0, 0, 0, 0, 0])); + assert_eq!(c.strings.len(), 1, "a failed load must not clobber content"); + } + + #[test] + fn header_length_must_match_the_blob() { + // Correct magic and version, but `total` claims more than we handed over. + let mut b = Vec::new(); + b.extend_from_slice(&spec::monpak::MAGIC.to_le_bytes()); + b.extend_from_slice(&spec::monpak::VERSION.to_le_bytes()); + b.extend_from_slice(&0u16.to_le_bytes()); // 0 sections + b.extend_from_slice(&999u32.to_le_bytes()); // lying length + b.extend_from_slice(&0u32.to_le_bytes()); + let mut c = Content::new(); + assert!(!c.load_pak(&b)); + } + + #[test] + fn empty_pak_loads() { + let mut b = Vec::new(); + b.extend_from_slice(&spec::monpak::MAGIC.to_le_bytes()); + b.extend_from_slice(&spec::monpak::VERSION.to_le_bytes()); + b.extend_from_slice(&0u16.to_le_bytes()); + b.extend_from_slice(&(spec::monpak::HEADER_SIZE as u32).to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + let mut c = Content::new(); + assert!(c.load_pak(&b)); + } + + #[test] + fn unknown_ids_return_safe_defaults() { + let c = Content::new(); + assert_eq!(c.string(9999), ""); + assert!(c.species_of(1).is_none()); + assert_eq!(c.type_category(200), spec::category::PHYSICAL); + assert_eq!(c.effectiveness(0, (0, 0)), spec::TYPE_SCALE); + } + + #[test] + fn matchup_rows_apply_once_per_row_not_per_type() { + let mut c = Content::new(); + c.types = vec![TypeDef::default(); 4]; + // One row: type 1 is 2x against type 2. + c.matchups.push(Matchup { attacker: 1, defender: 2, multiplier: 20 }); + let mut rows = Vec::new(); + // A defender that is type 2 twice must still take the row only once. + c.matchup_rows(1, (2, 2), &mut rows); + assert_eq!(rows, vec![20]); + // A genuine dual type with two matching rows takes both. + c.matchups.push(Matchup { attacker: 1, defender: 3, multiplier: 5 }); + c.matchup_rows(1, (2, 3), &mut rows); + assert_eq!(rows, vec![20, 5]); + assert_eq!(c.effectiveness(1, (2, 3)), 10); + } + + #[test] + fn map_border_ring_fills_outside_the_bounds() { + let m = MapDef { + width: 2, + height: 2, + border_block: 7, + blocks: vec![1, 2, 3, 4], + ..Default::default() + }; + assert_eq!(m.block_at(0, 0), 1); + assert_eq!(m.block_at(1, 1), 4); + assert_eq!(m.block_at(-1, 0), 7); + assert_eq!(m.block_at(2, 0), 7); + assert_eq!(m.block_at(0, -1), 7); + assert_eq!(m.block_at(0, 2), 7); + assert_eq!(m.width_cells(), 4); + } + + #[test] + fn learnset_slices_are_bounds_checked() { + let mut c = Content::new(); + c.learn_pool = vec![Learn { level: 1, move_id: 1 }, Learn { level: 5, move_id: 2 }]; + let ok = Species { learn_offset: 0, learn_count: 2, ..Default::default() }; + assert_eq!(c.learnset(&ok).len(), 2); + let overrun = Species { learn_offset: 1, learn_count: 9, ..Default::default() }; + assert!(c.learnset(&overrun).is_empty(), "an overrun slice yields nothing"); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/draw.rs b/engine/pocketmon/crates/pocketmon-core/src/draw.rs new file mode 100644 index 00000000..a424325a --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/draw.rs @@ -0,0 +1,286 @@ +//! The backend-independent frame output. +//! +//! The core emits one `MonDrawList` per frame in LOGICAL view pixels +//! (`spec::VIEW_W` x `spec::VIEW_H`); the host scales and rasterizes it. The +//! PSP backend (`pocketmon-gu`) turns quads straight into GE sprite vertices; +//! the headless sim backend rasterizes them into a byte buffer for goldens. +//! +//! ONE ordered stream of commands, drawn strictly in push order: +//! - `Quad` — textured, sampled from the CLUT8 atlas pages +//! - `Rect` — a solid ABGR fill (text boxes, HP bars, fades) +//! +//! It is deliberately one stream and not two arrays. Two arrays are cheaper to +//! batch — every textured draw in one pass, every flat fill in another — but +//! they cannot express "panel, then the text on the panel", and a backend that +//! drew all quads before all rects would paint every box over its own +//! contents. Backends batch *runs* of the same kind instead, which costs a +//! handful of state switches per frame and cannot get the order wrong. +//! +//! Layering is push order, not a sort key: the scene builder pushes ground +//! tiles, then actors ordered by their Y foot position, then UI. Keeping it +//! explicit means the PSP backend never sorts. + +use alloc::vec::Vec; + +use crate::spec; + +/// One textured quad from an atlas page. +/// +/// Coordinates are logical view pixels; `u`/`v` are texels into `page`. Sizes +/// are `u8` because nothing the core draws exceeds 255 px on a side — a +/// creature's battle portrait, the widest thing on screen, is 56x56. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Quad { + pub x: i16, + pub y: i16, + pub u: u16, + pub v: u16, + pub w: u8, + pub h: u8, + pub page: u8, + pub flags: u8, + pub tint: u32, +} + +/// A solid-color rectangle. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Rect { + pub x: i16, + pub y: i16, + pub w: u16, + pub h: u16, + pub color: u32, +} + +/// One entry in the frame's command stream. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DrawCmd { + Quad(Quad), + Rect(Rect), +} + +/// The per-frame draw list. Cleared and refilled every frame; the backing +/// allocation is reused, so a steady-state frame allocates nothing. +#[derive(Clone, Debug, Default)] +pub struct MonDrawList { + pub items: Vec, + /// Clip window in logical pixels, applied by the host. Full view by default. + pub clip: (i16, i16, u16, u16), + quad_count: u32, + rect_count: u32, +} + +impl MonDrawList { + pub fn new() -> Self { + MonDrawList { + items: Vec::new(), + clip: (0, 0, spec::VIEW_W as u16, spec::VIEW_H as u16), + quad_count: 0, + rect_count: 0, + } + } + + /// Drop everything but keep the capacity (the steady-state contract). + pub fn clear(&mut self) { + self.items.clear(); + self.clip = (0, 0, spec::VIEW_W as u16, spec::VIEW_H as u16); + self.quad_count = 0; + self.rect_count = 0; + } + + /// Textured draws this frame. + pub fn quads(&self) -> u32 { + self.quad_count + } + + /// Flat fills this frame. + pub fn rects(&self) -> u32 { + self.rect_count + } + + /// Push a quad, culling anything fully outside the view. + /// + /// Culling here (rather than in each caller) is what lets the scene builder + /// loop over a naive tile rectangle without worrying about the edges, and + /// it keeps the PSP vertex buffer small — the GE is fill-rate bound long + /// before it is vertex bound. + pub fn quad(&mut self, q: Quad) { + if q.w == 0 || q.h == 0 { + return; + } + let (x0, y0) = (q.x as i32, q.y as i32); + let (x1, y1) = (x0 + q.w as i32, y0 + q.h as i32); + if x1 <= 0 || y1 <= 0 || x0 >= spec::VIEW_W || y0 >= spec::VIEW_H { + return; + } + self.items.push(DrawCmd::Quad(q)); + self.quad_count += 1; + } + + /// Push an untinted 8x8 tile from a page. + pub fn tile(&mut self, x: i32, y: i32, u: u16, v: u16, page: u8, flags: u8) { + self.quad(Quad { + x: clamp_i16(x), + y: clamp_i16(y), + u, + v, + w: spec::TILE_PX as u8, + h: spec::TILE_PX as u8, + page, + flags, + tint: spec::TINT_NONE, + }); + } + + /// Push a solid rect, clipped to the view. + pub fn rect(&mut self, x: i32, y: i32, w: i32, h: i32, color: u32) { + let x0 = x.max(0); + let y0 = y.max(0); + let x1 = (x + w).min(spec::VIEW_W); + let y1 = (y + h).min(spec::VIEW_H); + if x1 <= x0 || y1 <= y0 { + return; + } + self.items.push(DrawCmd::Rect(Rect { + x: x0 as i16, + y: y0 as i16, + w: (x1 - x0) as u16, + h: (y1 - y0) as u16, + color, + })); + self.rect_count += 1; + } + + /// A one-pixel-thick outlined box — the frame every menu and textbox uses. + pub fn frame(&mut self, x: i32, y: i32, w: i32, h: i32, fill: u32, border: u32) { + self.rect(x, y, w, h, border); + self.rect(x + 1, y + 1, w - 2, h - 2, fill); + } + + /// Total drawable count, for the frame-stats counters. + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +/// Saturating i32 -> i16 for coordinates that can run far off-screen while a +/// map scrolls (a wrapped i16 would teleport a tile to the opposite edge). +#[inline] +pub fn clamp_i16(v: i32) -> i16 { + if v < i16::MIN as i32 { + i16::MIN + } else if v > i16::MAX as i32 { + i16::MAX + } else { + v as i16 + } +} + +/// Pack r/g/b/a into the repo-wide u32 ABGR (0xAABBGGRR) color. +#[inline] +pub const fn abgr(r: u8, g: u8, b: u8, a: u8) -> u32 { + (a as u32) << 24 | (b as u32) << 16 | (g as u32) << 8 | r as u32 +} + +/// Opaque ABGR from r/g/b. +#[inline] +pub const fn rgb(r: u8, g: u8, b: u8) -> u32 { + abgr(r, g, b, 255) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn q(x: i32, y: i32) -> Quad { + Quad { + x: clamp_i16(x), + y: clamp_i16(y), + u: 0, + v: 0, + w: 8, + h: 8, + page: 0, + flags: 0, + tint: spec::TINT_NONE, + } + } + + #[test] + fn offscreen_quads_are_culled() { + let mut d = MonDrawList::new(); + d.quad(q(-8, 0)); // fully left + d.quad(q(0, -8)); // fully above + d.quad(q(spec::VIEW_W, 0)); // fully right + d.quad(q(0, spec::VIEW_H)); // fully below + assert_eq!(d.quads(), 0); + d.quad(q(-7, 0)); // one pixel visible + d.quad(q(spec::VIEW_W - 1, spec::VIEW_H - 1)); + assert_eq!(d.quads(), 2); + } + + #[test] + fn zero_sized_quads_are_dropped() { + let mut d = MonDrawList::new(); + let mut z = q(0, 0); + z.w = 0; + d.quad(z); + assert_eq!(d.quads(), 0); + } + + #[test] + fn rects_clip_to_the_view() { + let mut d = MonDrawList::new(); + d.rect(-10, -10, 20, 20, 0xff00_00ff); + assert_eq!(d.rects(), 1); + let DrawCmd::Rect(r) = d.items[0] else { panic!("expected a rect") }; + assert_eq!((r.x, r.y, r.w, r.h), (0, 0, 10, 10)); + // fully outside contributes nothing + d.rect(-40, 0, 10, 10, 0); + d.rect(0, spec::VIEW_H + 4, 10, 10, 0); + assert_eq!(d.rects(), 1); + } + + #[test] + fn clear_keeps_capacity() { + let mut d = MonDrawList::new(); + for i in 0..64 { + d.quad(q(i, 0)); + } + let cap = d.items.capacity(); + d.clear(); + assert!(d.is_empty()); + assert_eq!(d.items.capacity(), cap, "clear must not free the buffer"); + } + + #[test] + fn coordinates_saturate_instead_of_wrapping() { + assert_eq!(clamp_i16(100_000), i16::MAX); + assert_eq!(clamp_i16(-100_000), i16::MIN); + } + + #[test] + fn push_order_is_preserved_across_kinds() { + // The whole reason this is one stream: a panel drawn after art must + // land after it, and text after the panel. + let mut d = MonDrawList::new(); + d.rect(0, 0, 8, 8, 1); + d.quad(q(0, 0)); + d.rect(0, 0, 4, 4, 2); + assert!(matches!(d.items[0], DrawCmd::Rect(_))); + assert!(matches!(d.items[1], DrawCmd::Quad(_))); + assert!(matches!(d.items[2], DrawCmd::Rect(_))); + assert_eq!((d.quads(), d.rects()), (1, 2)); + } + + #[test] + fn color_packing_is_abgr() { + // 0xAABBGGRR: red is the low byte, alpha the high one. + assert_eq!(rgb(0x12, 0x34, 0x56), 0xff56_3412); + assert_eq!(abgr(0, 0, 0, 0), 0); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/event.rs b/engine/pocketmon/crates/pocketmon-core/src/event.rs new file mode 100644 index 00000000..74ccf20e --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/event.rs @@ -0,0 +1,168 @@ +//! Core -> guest facts (Law 2 in docs/MON.md §5). +//! +//! The core never calls into the guest. Instead it appends facts to this +//! queue, and the guest drains the whole batch once per tick with the +//! `events()` op. Records are fixed-size and numeric so marshalling across the +//! QuickJS boundary costs one typed-array copy, not N property writes. + +use alloc::vec::Vec; + +use crate::spec; + +/// One fact. The meaning of `a`/`b`/`c`/`d` is per-kind and documented on the +/// `MON_EVENT` table in contracts/spec/mon-spec.ts. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MonEvent { + pub kind: u16, + pub a: u16, + pub b: i32, + pub c: i32, + pub d: i32, +} + +/// A bounded per-tick batch. +#[derive(Clone, Debug, Default)] +pub struct EventQueue { + events: Vec, + /// How many events the CORE has already reacted to. + /// + /// The core dispatches some of its own facts (an encounter starts a + /// battle, a talk starts a script) while the guest still gets the whole + /// batch. Without this watermark the core would re-handle every event on + /// every tick for as long as the batch sat undrained — which reopens the + /// conversation you just closed, forever. + dispatched: usize, + /// Events dropped because the batch was full — surfaced in frame stats so + /// an event storm is visible instead of silent. + pub dropped: u32, +} + +impl EventQueue { + pub fn new() -> Self { + EventQueue { events: Vec::new(), dispatched: 0, dropped: 0 } + } + + /// Append a fact. Drops (and counts) anything past `spec::EVENT_CAP`: + /// a fixed ceiling keeps the boundary buffer a compile-time size on the + /// PSP, and losing the tail of a runaway batch beats unbounded growth. + pub fn push(&mut self, e: MonEvent) { + if self.events.len() >= spec::EVENT_CAP { + self.dropped = self.dropped.saturating_add(1); + return; + } + self.events.push(e); + } + + pub fn len(&self) -> usize { + self.events.len() + } + + pub fn is_empty(&self) -> bool { + self.events.is_empty() + } + + /// The current batch, without clearing (for tests and inspection). + pub fn peek(&self) -> &[MonEvent] { + &self.events + } + + /// Events the core has not reacted to yet, marking them handled. + pub fn take_undispatched(&mut self) -> Vec { + let from = self.dispatched.min(self.events.len()); + self.dispatched = self.events.len(); + self.events[from..].to_vec() + } + + /// Take the batch, leaving the queue empty and the capacity intact. + pub fn drain(&mut self) -> Vec { + self.dispatched = 0; + core::mem::take(&mut self.events) + } + + /// Clear without allocating a replacement (the per-tick path). + pub fn clear(&mut self) { + self.events.clear(); + self.dispatched = 0; + } + + /// Serialize into the packed wire layout the `events()` op returns: + /// `u16 kind | u16 a | i32 b | i32 c | i32 d`, `spec::EVENT_SIZE` bytes each. + pub fn encode(&self, out: &mut Vec) { + out.clear(); + out.reserve(self.events.len() * spec::EVENT_SIZE); + for e in &self.events { + out.extend_from_slice(&e.kind.to_le_bytes()); + out.extend_from_slice(&e.a.to_le_bytes()); + out.extend_from_slice(&e.b.to_le_bytes()); + out.extend_from_slice(&e.c.to_le_bytes()); + out.extend_from_slice(&e.d.to_le_bytes()); + } + } + + /// The first event of a kind, if present. + pub fn find(&self, kind: u16) -> Option<&MonEvent> { + self.events.iter().find(|e| e.kind == kind) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn encoding_matches_the_pinned_record_size() { + let mut q = EventQueue::new(); + q.push(MonEvent { kind: spec::event::TALK, a: 1, b: -2, c: 3, d: 4 }); + q.push(MonEvent { kind: spec::event::SIGN, a: 0, b: 7, c: 0, d: 0 }); + let mut buf = Vec::new(); + q.encode(&mut buf); + assert_eq!(buf.len(), 2 * spec::EVENT_SIZE); + // First record, little-endian. + assert_eq!(u16::from_le_bytes([buf[0], buf[1]]), spec::event::TALK); + assert_eq!(u16::from_le_bytes([buf[2], buf[3]]), 1); + assert_eq!(i32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]), -2); + } + + #[test] + fn the_batch_is_capped_and_the_overflow_is_counted() { + let mut q = EventQueue::new(); + for i in 0..(spec::EVENT_CAP + 10) { + q.push(MonEvent { kind: spec::event::TALK, a: i as u16, b: 0, c: 0, d: 0 }); + } + assert_eq!(q.len(), spec::EVENT_CAP); + assert_eq!(q.dropped, 10); + } + + #[test] + fn the_core_sees_each_event_exactly_once() { + let mut q = EventQueue::new(); + q.push(MonEvent { kind: spec::event::TALK, a: 1, ..Default::default() }); + assert_eq!(q.take_undispatched().len(), 1); + // The batch is still there for the guest, but the core is done with it. + assert_eq!(q.len(), 1); + assert!(q.take_undispatched().is_empty(), "an event must not re-fire"); + q.push(MonEvent { kind: spec::event::SIGN, a: 2, ..Default::default() }); + let fresh = q.take_undispatched(); + assert_eq!(fresh.len(), 1); + assert_eq!(fresh[0].kind, spec::event::SIGN); + } + + #[test] + fn draining_resets_the_watermark() { + let mut q = EventQueue::new(); + q.push(MonEvent { kind: spec::event::TALK, ..Default::default() }); + q.take_undispatched(); + q.drain(); + q.push(MonEvent { kind: spec::event::SIGN, ..Default::default() }); + assert_eq!(q.take_undispatched().len(), 1, "a fresh batch dispatches again"); + } + + #[test] + fn draining_empties_the_queue() { + let mut q = EventQueue::new(); + q.push(MonEvent { kind: spec::event::WARPED, ..Default::default() }); + assert_eq!(q.drain().len(), 1); + assert!(q.is_empty()); + assert!(q.drain().is_empty()); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/lib.rs b/engine/pocketmon/crates/pocketmon-core/src/lib.rs new file mode 100644 index 00000000..b4bd84ab --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/lib.rs @@ -0,0 +1,679 @@ +//! pocketmon-core — the Pocket Mon RPG core. +//! +//! One [`Game`] owns the whole simulation: content registries, the world, the +//! party, the battle, the script VM, the text box, the save and the per-frame +//! draw list. Hosts call the op surface (docs/MON.md §3); nothing in here +//! knows about sceGu, QuickJS or a filesystem. +//! +//! Invariants upheld (all normative — the goldens depend on them): +//! - **No floating point.** Every rule is integer math, so a PSP, a wasm +//! host and an x86 test agree bit for bit. +//! - **No ambient randomness.** All rolls come from [`rng::Rng`], seeded by +//! the host, checkpointed into the save. +//! - **`tick()` advances exactly one 60 Hz frame**, and frame content is a +//! pure function of (tick index, input, seed). +//! - **No panics on content.** [`content`] is the only module that reads +//! untrusted bytes and it never indexes or unwraps. +//! - **The core ships no content.** Everything the game *is* arrives from +//! the guest (docs/MON.md §1: clean-room, no ROM, ever). + +#![cfg_attr(not(feature = "std"), no_std)] + +extern crate alloc; + +use alloc::vec; +use alloc::vec::Vec; + +pub mod battle; +pub mod content; +pub mod draw; +pub mod event; +pub mod mon; +pub mod rng; +pub mod save; +pub mod scene; +pub mod script; +pub mod spec; +pub mod surface; +pub mod text; +pub mod world; + +pub use content::Content; +pub use draw::MonDrawList; +pub use event::{EventQueue, MonEvent}; +pub use rng::Rng; +pub use world::{World, WorldGate}; + +use battle::Battle; +use mon::Party; +use script::ScriptVm; +use text::TextBox; + +/// The player's non-world state: who they are and what they carry. +#[derive(Clone, Debug, Default)] +pub struct PlayerState { + pub name_key: u16, + pub money: u32, + pub badges: u8, + pub party: Party, + pub bag: mon::Bag, + pub boxes: mon::Boxes, + /// Packed event flags, `spec::FLAG_COUNT` bits. + pub flags: Vec, + /// Species seen / caught, one bit each, sized to the loaded dex. + pub dex_seen: Vec, + pub dex_owned: Vec, +} + +impl PlayerState { + pub fn new() -> Self { + PlayerState { + flags: vec![0; spec::FLAG_COUNT / 8], + dex_seen: vec![0; 64], + dex_owned: vec![0; 64], + ..Default::default() + } + } + + pub fn flag(&self, id: u16) -> bool { + world::overworld::flag_set(&self.flags, id) + } + + pub fn set_flag(&mut self, id: u16, value: bool) { + world::overworld::set_flag(&mut self.flags, id, value); + } + + /// Mark a species seen; grows the bitset as content declares more species. + pub fn see(&mut self, species: u16) { + set_bit_growing(&mut self.dex_seen, species); + } + + pub fn own(&mut self, species: u16) { + set_bit_growing(&mut self.dex_seen, species); + set_bit_growing(&mut self.dex_owned, species); + } + + pub fn seen(&self, species: u16) -> bool { + get_bit(&self.dex_seen, species) + } + + pub fn owned(&self, species: u16) -> bool { + get_bit(&self.dex_owned, species) + } +} + +fn set_bit_growing(bits: &mut Vec, id: u16) { + let byte = id as usize / 8; + if byte >= bits.len() { + bits.resize(byte + 1, 0); + } + bits[byte] |= 1 << (id % 8); +} + +fn get_bit(bits: &[u8], id: u16) -> bool { + let byte = id as usize / 8; + bits.get(byte).is_some_and(|b| b & (1 << (id % 8)) != 0) +} + +/// Per-frame counters the host can surface for profiling (`frameStats`). +#[derive(Clone, Copy, Debug, Default)] +pub struct FrameStats { + pub tick: u32, + pub quads: u32, + pub rects: u32, + pub events: u32, + pub events_dropped: u32, + pub script_steps: u32, +} + +/// The whole simulation. +pub struct Game { + pub content: Content, + pub world: World, + pub player: PlayerState, + pub battle: Option, + pub script: ScriptVm, + pub text: TextBox, + pub rng: Rng, + pub events: EventQueue, + pub draw: MonDrawList, + pub stats: FrameStats, + pub mode: u8, + pub tick_count: u32, + /// Cursor for whichever native menu is up (battle action / move / switch). + pub menu_cursor: u8, + /// Music the host should be playing (-1 = silence). The core never talks + /// to an audio device; it only ever states what should be sounding. + pub music: i32, + /// One-shot sound effect for this frame, consumed by the host. + pub sfx: i32, + /// One-shot creature cry for this frame. + pub cry: i32, + /// Previous frame's button mask, for edge detection. + prev_buttons: u32, + /// Scratch buffer reused by the packed `events()` / `view()` reads. + scratch: Vec, +} + +impl Default for Game { + fn default() -> Self { + Game::new() + } +} + +impl Game { + pub fn new() -> Self { + Game { + content: Content::new(), + world: World::new(), + player: PlayerState::new(), + battle: None, + script: ScriptVm::new(), + text: TextBox::new(), + rng: Rng::default(), + events: EventQueue::new(), + draw: MonDrawList::new(), + stats: FrameStats::default(), + mode: spec::mode::OVERWORLD, + tick_count: 0, + menu_cursor: 0, + music: -1, + sfx: -1, + cry: -1, + prev_buttons: 0, + scratch: Vec::new(), + } + } + + /// Seed the deterministic RNG (op `seed`). + pub fn seed(&mut self, seed: u64) { + self.rng.reseed(seed); + } + + /// Load a cooked MONPAK (op `loadContent`). + pub fn load_content(&mut self, blob: &[u8]) -> bool { + self.content.load_pak(blob) + } + + /// Merge an additional MONPAK over the loaded content (the mod path). + pub fn merge_content(&mut self, blob: &[u8]) -> bool { + self.content.merge_pak(blob) + } + + /// Place the player (op `enterMap`). + pub fn enter_map(&mut self, map: u16, cx: i32, cy: i32, dir: u8) { + let flags = core::mem::take(&mut self.player.flags); + self.world.enter_map(&self.content, &flags, map, cx, cy, dir); + self.player.flags = flags; + } + + /// Is anything holding the world (text, battle, script, menu)? + /// + /// `script_was_running` is the state at the TOP of the frame, before the + /// VM ran. It matters: a script's last instruction executes in the same + /// frame as the button press that released it, and without this the gate + /// would already be open when the world reads that press — so closing a + /// conversation with A would immediately reopen it. The frame a + /// conversation ends is not a frame you can act on. + fn gate(&self, script_was_running: bool) -> WorldGate { + if script_was_running + || self.text.active() + || self.battle.is_some() + || self.script.running() + || self.mode == spec::mode::MENU + { + WorldGate::Held + } else { + WorldGate::Free + } + } + + /// Advance exactly one 60 Hz frame. + /// + /// Order matters and mirrors the upstream fixed-step loop: the script VM + /// runs first (so a script that opens a textbox this frame has it up + /// before anything reads the gate), then the battle or the world, then + /// the text box's typewriter. + pub fn tick(&mut self, buttons: u32) { + self.tick_count = self.tick_count.wrapping_add(1); + let pressed = buttons & !self.prev_buttons; + self.prev_buttons = buttons; + self.stats.script_steps = 0; + + let script_was_running = self.script.running(); + self.step_script(pressed); + + if self.battle.is_some() { + self.step_battle(pressed); + } else { + let gate = self.gate(script_was_running); + let mut world = core::mem::take(&mut self.world); + let flags = core::mem::take(&mut self.player.flags); + world.update( + &self.content, + &flags, + &mut self.rng, + buttons, + pressed, + gate, + &mut self.events, + ); + self.player.flags = flags; + self.world = world; + } + + self.text.tick(pressed, &mut self.events); + self.apply_pending_block(); + self.dispatch_events(); + self.sync_mode(); + self.stats.tick = self.tick_count; + self.stats.events = self.events.len() as u32; + self.stats.events_dropped = self.events.dropped; + } + + /// Apply a `replace_block` a script requested (the VM cannot reach + /// `Content` mutably from inside a borrow of it). + fn apply_pending_block(&mut self) { + if let Some((bx, by, block)) = self.world.pending_block.take() { + let map = self.world.map_id; + self.content.set_block(map, bx, by, block); + } + } + + /// React to the core's own events, and let the script VM see the ones it + /// is parked on. + /// + /// The events stay in the queue afterwards — the guest still drains the + /// whole batch. This is the core reacting to itself, exactly the way the + /// upstream overworld controller dispatches a talk to a script, a trainer + /// or a plain line of text, in that order. + fn dispatch_events(&mut self) { + // Only what is new this tick: the guest drains the full batch on its + // own schedule, and re-reacting to an old fact is how a conversation + // reopens itself forever. + let batch = self.events.take_undispatched(); + for e in batch { + match e.kind { + spec::event::TEXT_DONE => self.script.on_text_done(e.b), + spec::event::CHOICE_DONE => self.script.on_choice(e.b, e.c), + spec::event::ENCOUNTER => { + self.start_wild(e.a, e.b.clamp(1, spec::LEVEL_MAX as i32) as u8); + } + spec::event::TALK => self.on_talk(e.b as u16, e.c), + spec::event::SIGN => { + if !self.text.active() { + self.show_text_key(e.b as u16); + } + } + _ => {} + } + } + } + + /// The talk dispatch order, ported from the upstream controller: + /// a script keyed by the actor's text id, then the trainer path, then the + /// plain line. + fn on_talk(&mut self, text_key: u16, trainer_id: i32) { + if self.script.running() || self.text.active() { + return; + } + if let Some(program) = self.content.scripts.get(&text_key).cloned() { + self.script.start(&program); + return; + } + if trainer_id >= 0 { + self.start_trainer(trainer_id as u16); + return; + } + self.show_text_key(text_key); + } + + /// Run the script VM until it blocks or finishes. + fn step_script(&mut self, _pressed: u32) { + if !self.script.running() { + return; + } + // Split the borrows the VM needs; `Game` owns all of them. + let Game { + content, + world, + player, + text, + events, + rng, + script, + .. + } = self; + let mut ctx = script::ScriptCtx { + content, + world, + player, + text, + events, + rng, + battle: None, + music: None, + sfx: None, + cry: None, + }; + script.step(&mut ctx); + let (battle, music, sfx, cry) = (ctx.battle, ctx.music, ctx.sfx, ctx.cry); + self.stats.script_steps += 1; + + if let Some(m) = music { + self.music = m; + } + if let Some(s) = sfx { + self.sfx = s; + } + if let Some(c) = cry { + self.cry = c as i32; + } + match battle { + Some(script::BattleRequest::Wild { species, level }) => self.start_wild(species, level), + Some(script::BattleRequest::Trainer { id }) => self.start_trainer(id), + None => {} + } + } + + /// Begin a wild encounter with the party's first healthy creature. + pub fn start_wild(&mut self, species: u16, level: u8) { + if self.battle.is_some() { + return; + } + let Some(slot) = self.player.party.first_healthy() else { + // Nothing able to fight: the encounter simply does not happen, + // rather than opening a battle the player cannot act in. + return; + }; + let Some(wild) = mon::MonInstance::wild(&self.content, species, level, &mut self.rng) + else { + return; + }; + self.player.see(species); + let party = core::mem::take(&mut self.player.party); + match Battle::wild(&self.content, party, slot, wild) { + Some(b) => { + self.battle = Some(b); + self.menu_cursor = 0; + } + None => { + // `wild` only fails on a bad slot; put the party back rather + // than dropping it on the floor. + self.player.party = mon::Party::default(); + } + } + } + + /// Begin a trainer battle. + pub fn start_trainer(&mut self, id: u16) { + if self.battle.is_some() { + return; + } + let Some(trainer) = self.content.trainers.get(&id).cloned() else { + return; + }; + let Some(slot) = self.player.party.first_healthy() else { + return; + }; + let party = core::mem::take(&mut self.player.party); + match Battle::trainer(&self.content, party, slot, &trainer, &mut self.rng) { + Some(b) => { + self.battle = Some(b); + self.menu_cursor = 0; + } + None => self.player.party = mon::Party::default(), + } + } + + /// Advance the active battle one frame, reading the pad for its menus. + fn step_battle(&mut self, pressed: u32) { + let Some(mut battle) = self.battle.take() else { + return; + }; + battle.tick(); + + match battle.phase { + spec::phase::CHOOSE_ACTION => { + // A 2x2 grid: FIGHT ITEM / MON RUN. + if pressed & spec::btn::LEFT != 0 || pressed & spec::btn::RIGHT != 0 { + self.menu_cursor ^= 1; + } + if pressed & spec::btn::UP != 0 || pressed & spec::btn::DOWN != 0 { + self.menu_cursor ^= 2; + } + if pressed & spec::btn::A != 0 { + let action = self.menu_cursor.min(3); + battle.choose_action(action, &self.content, &mut self.rng); + self.menu_cursor = 0; + } + } + spec::phase::CHOOSE_MOVE => { + let n = spec::MOVES_MAX as u8; + if pressed & spec::btn::UP != 0 { + self.menu_cursor = (self.menu_cursor + n - 1) % n; + } + if pressed & spec::btn::DOWN != 0 { + self.menu_cursor = (self.menu_cursor + 1) % n; + } + if pressed & spec::btn::A != 0 { + battle.choose_move(self.menu_cursor as usize, &self.content, &mut self.rng); + } + if pressed & spec::btn::B != 0 { + battle.phase = spec::phase::CHOOSE_ACTION; + self.menu_cursor = 0; + } + } + spec::phase::CHOOSE_SWITCH => { + let n = battle.party.len().max(1) as u8; + if pressed & spec::btn::UP != 0 { + self.menu_cursor = (self.menu_cursor + n - 1) % n; + } + if pressed & spec::btn::DOWN != 0 { + self.menu_cursor = (self.menu_cursor + 1) % n; + } + if pressed & spec::btn::A != 0 { + battle.choose_switch(self.menu_cursor as usize, &self.content, &mut self.rng); + self.menu_cursor = 0; + } + if pressed & spec::btn::B != 0 && !battle.must_switch { + battle.phase = spec::phase::CHOOSE_ACTION; + self.menu_cursor = 0; + } + } + _ => { + if pressed & (spec::btn::A | spec::btn::B) != 0 { + battle.advance(&self.content, &mut self.rng); + } + } + } + + if battle.finished() { + self.end_battle(battle); + } else { + self.battle = Some(battle); + } + } + + /// Tear down a finished battle and apply its consequences. + fn end_battle(&mut self, mut battle: Battle) { + let outcome = battle.outcome.unwrap_or(spec::outcome::DRAW); + battle.emit_end(&mut self.events); + + // A caught creature joins the party, or a box when the party is full. + if let Some(caught) = battle.caught.take() { + let species = caught.species; + let level = caught.level; + self.player.own(species); + let slot = if battle.party.full() { + self.player.boxes.deposit(caught); + -1 + } else { + battle.party.add(caught).map(|s| s as i32).unwrap_or(-1) + }; + self.events.push(MonEvent { + kind: spec::event::CAUGHT, + a: species, + b: level as i32, + c: slot, + d: 0, + }); + } + + if outcome == spec::outcome::WIN { + self.player.money = self.player.money.saturating_add(battle.reward); + } + + self.player.party = battle.take_party(); + self.script.on_battle_end(outcome); + self.battle = None; + self.menu_cursor = 0; + + // A total loss sends the player back to the last outdoor map, healed — + // the genre's standard "black out" rather than a game over screen. + if outcome == spec::outcome::LOSS { + self.player.party.heal_all(); + let home = self.world.last_outdoor; + if self.content.map_of(home).is_some() { + self.world.warp_to(home, 0, 0, spec::dir::DOWN, true); + } + } + } + + /// Save the whole game state. + pub fn save(&self) -> Vec { + save::save(save::Snapshot { + player: &self.player, + world: &self.world, + content: &self.content, + rng: &self.rng, + }) + } + + /// Restore a save. Returns false (changing nothing) if it does not parse. + pub fn load(&mut self, bytes: &[u8]) -> bool { + let Some(mut loaded) = save::load(bytes) else { + return false; + }; + save::rehydrate(&mut loaded.player, &self.content); + self.player = loaded.player; + self.rng.set_state(loaded.rng_state); + self.battle = None; + self.script.stop(); + self.text.close(); + for (key, block) in loaded.block_overrides { + self.content.block_overrides.insert(key, block); + } + let flags = core::mem::take(&mut self.player.flags); + self.world + .enter_map(&self.content, &flags, loaded.map, loaded.cx, loaded.cy, loaded.dir); + self.player.flags = flags; + self.world.last_outdoor = loaded.last_outdoor; + self.world.steps = loaded.steps; + self.world.surfing = loaded.surfing; + true + } + + /// Keep `mode` a pure function of what is actually up, so the guest can + /// trust it without tracking transitions itself. + fn sync_mode(&mut self) { + self.mode = if self.battle.is_some() { + spec::mode::BATTLE + } else if self.world.fade.active { + spec::mode::TRANSITION + } else if self.text.active() { + spec::mode::TEXT + } else if self.mode == spec::mode::MENU { + spec::mode::MENU + } else { + spec::mode::OVERWORLD + }; + } + + /// Build this frame's draw list. + pub fn render(&mut self) -> &MonDrawList { + self.draw.clear(); + if self.battle.is_some() { + scene::draw_battle(self); + } else { + scene::draw_world(self); + } + scene::draw_text(self); + scene::draw_fade(self); + self.stats.quads = self.draw.quads(); + self.stats.rects = self.draw.rects(); + &self.draw + } + + /// Drain the per-tick event batch into the packed wire layout. + pub fn encode_events(&mut self) -> &[u8] { + self.events.encode(&mut self.scratch); + self.events.clear(); + &self.scratch + } + + /// Push a textbox (op `showText`). + /// + /// Destructured rather than `self.text.show(&self.content, …)` because the + /// borrow checker cannot see that the two fields are disjoint through a + /// method call on `self`. + pub fn show_text(&mut self, s: &str) -> i32 { + let Game { text, content, .. } = self; + text.show(content, s) + } + + /// Open a choice box (op `showChoice`). + pub fn show_choice(&mut self, s: &str, options: &[&str]) -> i32 { + let Game { text, content, .. } = self; + text.show_choice(content, s, options) + } + + /// Show a string from the content's text table by key. + pub fn show_text_key(&mut self, key: u16) -> i32 { + let Game { text, content, .. } = self; + // The string is owned by `content`, which `show` also borrows; clone + // the (short) line rather than fighting the aliasing. + let s = alloc::string::String::from(content.string(key)); + text.show(content, &s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_fresh_game_ticks_without_content() { + // The core ships no content; booting with an empty registry must be + // inert, not a crash — the guest uploads the game a frame later. + let mut g = Game::new(); + for _ in 0..120 { + g.tick(spec::btn::A | spec::btn::DOWN); + g.render(); + } + assert_eq!(g.tick_count, 120); + assert_eq!(g.mode, spec::mode::OVERWORLD); + } + + #[test] + fn dex_bits_grow_with_the_species_id() { + let mut p = PlayerState::new(); + assert!(!p.seen(1000)); + p.see(1000); + assert!(p.seen(1000)); + assert!(!p.owned(1000)); + p.own(1000); + assert!(p.owned(1000) && p.seen(1000)); + } + + #[test] + fn edge_detection_fires_once_per_press() { + let mut g = Game::new(); + g.tick(spec::btn::A); + let first = g.tick_count; + g.tick(spec::btn::A); + assert_eq!(g.tick_count, first + 1); + // Held buttons must not re-trigger: the textbox advance depends on it. + assert_eq!(g.prev_buttons, spec::btn::A); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/mon/growth.rs b/engine/pocketmon/crates/pocketmon-core/src/mon/growth.rs new file mode 100644 index 00000000..a43e9a9d --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/mon/growth.rs @@ -0,0 +1,148 @@ +//! Experience growth curves. +//! +//! Ported from upstream `src/pokemon/Growth.lua`, which lists the six curve +//! polynomials. `n` is the level; the result is the total experience required +//! to *be* that level. +//! +//! The low-level end of MEDIUM_SLOW is famously negative before the clamp +//! (`-140` at n = 0, and it dips again around n = 1..2), so every curve is +//! clamped at zero — the upstream code does the same, and a negative +//! threshold would let a level-1 creature satisfy level 2 instantly. + +use crate::spec; + +/// Total experience needed to reach `level` on `curve`. +pub fn exp_for_level(curve: u8, level: u8) -> u32 { + let n = level as i64; + let raw: i64 = match curve { + spec::growth::MEDIUM_FAST => n * n * n, + spec::growth::SLIGHTLY_FAST => (3 * n * n * n) / 4 + 10 * n * n - 30, + spec::growth::SLIGHTLY_SLOW => (3 * n * n * n) / 4 + 20 * n * n - 70, + spec::growth::MEDIUM_SLOW => (6 * n * n * n) / 5 - 15 * n * n + 100 * n - 140, + spec::growth::FAST => (4 * n * n * n) / 5, + spec::growth::SLOW => (5 * n * n * n) / 4, + // An unknown curve behaves as MEDIUM_FAST rather than mis-levelling. + _ => n * n * n, + }; + raw.max(0) as u32 +} + +/// The level a given experience total corresponds to on `curve`. +pub fn level_for_exp(curve: u8, exp: u32, cap: u8) -> u8 { + let cap = cap.clamp(1, spec::LEVEL_MAX as u8); + let mut level = 1u8; + while level < cap && exp_for_level(curve, level + 1) <= exp { + level += 1; + } + level +} + +/// Experience still needed to reach the next level (0 at the cap). +pub fn exp_to_next(curve: u8, level: u8, exp: u32) -> u32 { + if level as u32 >= spec::LEVEL_MAX { + return 0; + } + exp_for_level(curve, level + 1).saturating_sub(exp) +} + +/// Experience awarded for defeating one creature. +/// +/// `base * level / 7`, halved when the winner was not the active participant +/// (upstream `src/battle/Experience.lua`); trainer battles pay 1.5x. +pub fn exp_award(base_exp: u16, level: u8, trainer: bool, participants: u32) -> u32 { + let mut v = base_exp as u32 * level as u32 / 7; + if trainer { + v = v * 3 / 2; + } + v / participants.max(1) +} + +#[cfg(test)] +mod tests { + use super::*; + + const CURVES: [u8; 6] = [ + spec::growth::MEDIUM_FAST, + spec::growth::SLIGHTLY_FAST, + spec::growth::SLIGHTLY_SLOW, + spec::growth::MEDIUM_SLOW, + spec::growth::FAST, + spec::growth::SLOW, + ]; + + #[test] + fn medium_fast_is_the_cube() { + assert_eq!(exp_for_level(spec::growth::MEDIUM_FAST, 1), 1); + assert_eq!(exp_for_level(spec::growth::MEDIUM_FAST, 10), 1000); + assert_eq!(exp_for_level(spec::growth::MEDIUM_FAST, 100), 1_000_000); + } + + #[test] + fn the_named_curves_hit_their_level_100_totals() { + assert_eq!(exp_for_level(spec::growth::FAST, 100), 800_000); + assert_eq!(exp_for_level(spec::growth::SLOW, 100), 1_250_000); + assert_eq!(exp_for_level(spec::growth::MEDIUM_SLOW, 100), 1_059_860); + assert_eq!(exp_for_level(spec::growth::SLIGHTLY_FAST, 100), 849_970); + assert_eq!(exp_for_level(spec::growth::SLIGHTLY_SLOW, 100), 949_930); + } + + #[test] + fn every_curve_is_non_decreasing_and_never_negative() { + for curve in CURVES { + let mut last = 0; + for lv in 1..=100u8 { + let e = exp_for_level(curve, lv); + assert!(e >= last, "curve {curve} dipped at level {lv}: {e} < {last}"); + last = e; + } + } + } + + #[test] + fn medium_slow_is_clamped_at_the_low_end() { + // The raw polynomial is negative here; the clamp must show through. + assert_eq!(exp_for_level(spec::growth::MEDIUM_SLOW, 0), 0); + assert!(exp_for_level(spec::growth::MEDIUM_SLOW, 1) < 10); + } + + #[test] + fn level_lookup_inverts_the_curve() { + for curve in CURVES { + for lv in 1..=100u8 { + let e = exp_for_level(curve, lv); + assert_eq!(level_for_exp(curve, e, 100), lv, "curve {curve}, level {lv}"); + // One point below the threshold is still the previous level. + if lv > 1 && e > 0 { + assert_eq!(level_for_exp(curve, e - 1, 100), lv - 1); + } + } + } + } + + #[test] + fn the_level_cap_is_respected() { + let huge = exp_for_level(spec::growth::MEDIUM_FAST, 100) * 4; + assert_eq!(level_for_exp(spec::growth::MEDIUM_FAST, huge, 100), 100); + assert_eq!(level_for_exp(spec::growth::MEDIUM_FAST, huge, 20), 20); + } + + #[test] + fn unknown_curves_fall_back_to_medium_fast() { + assert_eq!(exp_for_level(200, 10), exp_for_level(spec::growth::MEDIUM_FAST, 10)); + } + + #[test] + fn exp_to_next_reaches_zero_at_the_cap() { + assert_eq!(exp_to_next(spec::growth::MEDIUM_FAST, 100, 0), 0); + assert_eq!(exp_to_next(spec::growth::MEDIUM_FAST, 1, 1), 7); // 8 - 1 + } + + #[test] + fn awards_scale_with_level_and_split_between_participants() { + // base 64, level 7 -> 64 * 7 / 7 = 64 + assert_eq!(exp_award(64, 7, false, 1), 64); + assert_eq!(exp_award(64, 7, true, 1), 96, "trainers pay 1.5x"); + assert_eq!(exp_award(64, 7, false, 2), 32); + assert_eq!(exp_award(64, 7, false, 0), 64, "zero participants never divides by zero"); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/mon/mod.rs b/engine/pocketmon/crates/pocketmon-core/src/mon/mod.rs new file mode 100644 index 00000000..45149f93 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/mon/mod.rs @@ -0,0 +1,650 @@ +//! Creature instances and the containers that hold them: party, boxes, bag. + +pub mod growth; +pub mod stats; + +use alloc::vec::Vec; + +use crate::content::{Content, Species}; +use crate::rng::Rng; +use crate::spec; + +pub use stats::{Dvs, StatExp, Stages, Stats}; + +/// One move slot on a creature. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct MoveSlot { + pub id: u16, + pub pp: u8, + pub pp_max: u8, +} + +impl MoveSlot { + pub fn empty(&self) -> bool { + self.id == 0 + } +} + +/// A creature instance — what the party and the boxes store. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MonInstance { + pub species: u16, + pub level: u8, + pub exp: u32, + pub hp: u16, + pub max_hp: u16, + pub status: u8, + /// Turns of sleep remaining (only meaningful with `status == SLEEP`). + pub sleep: u8, + pub dvs: Dvs, + pub stat_exp: StatExp, + pub moves: [MoveSlot; spec::MOVES_MAX], + pub nickname_key: u16, + /// Non-zero when the creature came from someone else (trade rules). + pub original_trainer: u16, +} + +impl MonInstance { + /// Build a wild/gift creature at a level, rolling DVs and filling the + /// most recent four learnset moves — the original's wild-creature setup. + pub fn wild(content: &Content, species_id: u16, level: u8, rng: &mut Rng) -> Option { + let sp = content.species_of(species_id)?; + let dvs = Dvs::roll(rng); + let mut m = MonInstance { + species: species_id, + level, + exp: growth::exp_for_level(sp.growth, level), + dvs, + ..Default::default() + }; + m.fill_moves(content, sp); + m.recalc(content); + m.hp = m.max_hp; + Some(m) + } + + /// Build a creature with explicit moves (trainer rosters, scripted gifts). + pub fn with_moves( + content: &Content, + species_id: u16, + level: u8, + moves: &[u16], + dvs: Dvs, + ) -> Option { + let sp = content.species_of(species_id)?; + let mut m = MonInstance { + species: species_id, + level, + exp: growth::exp_for_level(sp.growth, level), + dvs, + ..Default::default() + }; + let mut wrote = false; + for (slot, &id) in m.moves.iter_mut().zip(moves.iter()) { + if id == 0 { + continue; + } + let pp = content.move_of(id).map(|mv| mv.pp).unwrap_or(0); + *slot = MoveSlot { id, pp, pp_max: pp }; + wrote = true; + } + // An all-zero roster falls back to the learnset rather than fielding a + // creature with no moves at all. + if !wrote { + m.fill_moves(content, sp); + } + m.recalc(content); + m.hp = m.max_hp; + Some(m) + } + + /// Fill the move slots with the last four moves learnable at this level. + fn fill_moves(&mut self, content: &Content, sp: &Species) { + self.moves = [MoveSlot::default(); spec::MOVES_MAX]; + let learnset = content.learnset(sp); + let mut chosen: Vec = Vec::new(); + for l in learnset { + if l.level as u16 <= self.level as u16 && l.move_id != 0 { + chosen.push(l.move_id); + } + } + let start = chosen.len().saturating_sub(spec::MOVES_MAX); + for (slot, &id) in self.moves.iter_mut().zip(chosen[start..].iter()) { + let pp = content.move_of(id).map(|m| m.pp).unwrap_or(0); + *slot = MoveSlot { id, pp, pp_max: pp }; + } + } + + /// Recompute `max_hp` and keep current HP proportional-safe. + /// + /// A level-up must not heal, but it must not leave a creature with more HP + /// than its new maximum either; the original adds the *difference* to + /// current HP, which is what the level-up screen shows. + pub fn recalc(&mut self, content: &Content) { + let Some(sp) = content.species_of(self.species) else { + return; + }; + let st = stats::calc(sp, self.level, &self.dvs, &self.stat_exp); + let gain = st.hp.saturating_sub(self.max_hp); + self.max_hp = st.hp; + if self.max_hp == 0 { + self.hp = 0; + } else { + self.hp = (self.hp + gain).min(self.max_hp); + } + } + + /// The full stat block at the current level. + pub fn stats(&self, content: &Content) -> Stats { + match content.species_of(self.species) { + Some(sp) => stats::calc(sp, self.level, &self.dvs, &self.stat_exp), + None => Stats::default(), + } + } + + pub fn fainted(&self) -> bool { + self.hp == 0 + } + + /// The creature's two types (both equal for a single-typed species). + pub fn types(&self, content: &Content) -> (u8, u8) { + match content.species_of(self.species) { + Some(sp) => (sp.type1, sp.type2), + None => (0, 0), + } + } + + /// Grant experience. Returns the number of levels gained. + pub fn gain_exp(&mut self, content: &Content, amount: u32) -> u8 { + let Some(sp) = content.species_of(self.species) else { + return 0; + }; + let curve = sp.growth; + let cap = spec::LEVEL_MAX as u8; + self.exp = self + .exp + .saturating_add(amount) + .min(growth::exp_for_level(curve, cap)); + let before = self.level; + let after = growth::level_for_exp(curve, self.exp, cap); + if after > before { + self.level = after; + self.recalc(content); + } + after.saturating_sub(before) + } + + /// Moves this creature learns on reaching its current level. + pub fn moves_learned_at(&self, content: &Content, level: u8) -> Vec { + let Some(sp) = content.species_of(self.species) else { + return Vec::new(); + }; + content + .learnset(sp) + .iter() + .filter(|l| l.level == level as u16 && l.move_id != 0) + .map(|l| l.move_id) + .collect() + } + + /// Teach a move into the first free slot. Returns false when full. + pub fn learn(&mut self, content: &Content, move_id: u16) -> bool { + if move_id == 0 || self.moves.iter().any(|m| m.id == move_id) { + return true; // already known: nothing to do, not a failure + } + let pp = content.move_of(move_id).map(|m| m.pp).unwrap_or(0); + for slot in self.moves.iter_mut() { + if slot.empty() { + *slot = MoveSlot { id: move_id, pp, pp_max: pp }; + return true; + } + } + false + } + + /// Overwrite a move slot (the "forget a move" flow). + pub fn replace_move(&mut self, content: &Content, idx: usize, move_id: u16) { + let pp = content.move_of(move_id).map(|m| m.pp).unwrap_or(0); + if let Some(slot) = self.moves.get_mut(idx) { + *slot = MoveSlot { id: move_id, pp, pp_max: pp }; + } + } + + /// Restore HP, capped at the maximum. Returns the amount actually healed. + pub fn heal(&mut self, amount: u16) -> u16 { + let before = self.hp; + self.hp = (self.hp + amount).min(self.max_hp); + self.hp - before + } + + /// Full restore: HP, status and PP. + pub fn heal_full(&mut self) { + self.hp = self.max_hp; + self.status = spec::status::NONE; + self.sleep = 0; + for m in self.moves.iter_mut() { + m.pp = m.pp_max; + } + } + + /// Apply damage, floored at zero. + pub fn damage(&mut self, amount: u16) -> u16 { + let dealt = amount.min(self.hp); + self.hp -= dealt; + dealt + } + + /// Which species this evolves into right now, if any. + pub fn evolution(&self, content: &Content) -> Option { + let sp = content.species_of(self.species)?; + match sp.evolve_kind { + crate::content::evolve::LEVEL if self.level as u16 >= sp.evolve_param => { + Some(sp.evolve_into) + } + _ => None, + } + } +} + +/// The active party: 1..=6 creatures, slot 0 leads. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Party { + pub mons: Vec, +} + +impl Party { + pub fn len(&self) -> usize { + self.mons.len() + } + + pub fn is_empty(&self) -> bool { + self.mons.is_empty() + } + + pub fn full(&self) -> bool { + self.mons.len() >= spec::PARTY_MAX + } + + pub fn get(&self, i: usize) -> Option<&MonInstance> { + self.mons.get(i) + } + + pub fn get_mut(&mut self, i: usize) -> Option<&mut MonInstance> { + self.mons.get_mut(i) + } + + /// Add a creature. Returns its slot, or None when the party is full. + pub fn add(&mut self, m: MonInstance) -> Option { + if self.full() { + return None; + } + self.mons.push(m); + Some(self.mons.len() - 1) + } + + /// The first creature that can still fight. + pub fn first_healthy(&self) -> Option { + self.mons.iter().position(|m| !m.fainted()) + } + + /// Is the whole party down? (An empty party counts as wiped.) + pub fn wiped(&self) -> bool { + self.first_healthy().is_none() + } + + pub fn heal_all(&mut self) { + for m in self.mons.iter_mut() { + m.heal_full(); + } + } + + /// Swap two slots (the party-menu reorder). + pub fn swap(&mut self, a: usize, b: usize) { + if a != b && a < self.mons.len() && b < self.mons.len() { + self.mons.swap(a, b); + } + } +} + +/// One stored item stack. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct BagSlot { + pub item: u16, + pub qty: u8, +} + +/// The bag: a list of stacks, capped at `spec::BAG_MAX` distinct items. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Bag { + pub slots: Vec, +} + +impl Bag { + pub fn count(&self, item: u16) -> u8 { + self.slots.iter().find(|s| s.item == item).map(|s| s.qty).unwrap_or(0) + } + + pub fn has(&self, item: u16) -> bool { + self.count(item) > 0 + } + + /// Add to an existing stack or open a new one. Returns false only when the + /// bag has no room for a new distinct item. + pub fn add(&mut self, item: u16, qty: u8) -> bool { + if item == 0 || qty == 0 { + return true; + } + if let Some(s) = self.slots.iter_mut().find(|s| s.item == item) { + s.qty = s.qty.saturating_add(qty).min(99); + return true; + } + if self.slots.len() >= spec::BAG_MAX { + return false; + } + self.slots.push(BagSlot { item, qty: qty.min(99) }); + true + } + + /// Remove from a stack, dropping the stack when it empties. Returns false + /// when there was not enough to take. + pub fn take(&mut self, item: u16, qty: u8) -> bool { + let Some(idx) = self.slots.iter().position(|s| s.item == item) else { + return false; + }; + if self.slots[idx].qty < qty { + return false; + } + self.slots[idx].qty -= qty; + if self.slots[idx].qty == 0 { + self.slots.remove(idx); + } + true + } +} + +/// Storage boxes: `spec::BOX_COUNT` boxes of `spec::BOX_SIZE` each. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Boxes { + pub boxes: Vec>, + pub current: u8, +} + +impl Boxes { + pub fn new() -> Self { + Boxes { + boxes: (0..spec::BOX_COUNT).map(|_| Vec::new()).collect(), + current: 0, + } + } + + /// Deposit into the current box, spilling into the next box with room. + /// Returns the box index used, or None when every box is full. + pub fn deposit(&mut self, m: MonInstance) -> Option { + if self.boxes.is_empty() { + *self = Boxes::new(); + } + let n = self.boxes.len(); + for step in 0..n { + let idx = (self.current as usize + step) % n; + if self.boxes[idx].len() < spec::BOX_SIZE { + self.boxes[idx].push(m); + return Some(idx); + } + } + None + } + + pub fn withdraw(&mut self, box_idx: usize, slot: usize) -> Option { + let b = self.boxes.get_mut(box_idx)?; + if slot >= b.len() { + return None; + } + Some(b.remove(slot)) + } + + pub fn total(&self) -> usize { + self.boxes.iter().map(Vec::len).sum() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::{Learn, Move}; + use alloc::vec; + + fn content() -> Content { + let mut c = Content::new(); + c.species.insert( + 1, + Species { + id: 1, + base_hp: 45, + base_atk: 49, + base_def: 49, + base_spd: 45, + base_spc: 65, + type1: 1, + type2: 1, + catch_rate: 45, + base_exp: 64, + growth: spec::growth::MEDIUM_SLOW, + learn_offset: 0, + learn_count: 5, + evolve_kind: crate::content::evolve::LEVEL, + evolve_param: 16, + evolve_into: 2, + ..Default::default() + }, + ); + c.species.insert(2, Species { id: 2, base_hp: 60, growth: spec::growth::MEDIUM_SLOW, ..Default::default() }); + c.learn_pool = vec![ + Learn { level: 1, move_id: 10 }, + Learn { level: 1, move_id: 11 }, + Learn { level: 7, move_id: 12 }, + Learn { level: 13, move_id: 13 }, + Learn { level: 20, move_id: 14 }, + ]; + for id in 10..=14u16 { + c.moves.insert(id, Move { id, pp: 25, power: 40, accuracy: 100, ..Default::default() }); + } + c + } + + #[test] + fn a_wild_creature_starts_at_full_hp_with_level_moves() { + let c = content(); + let mut rng = Rng::new(1); + let m = MonInstance::wild(&c, 1, 7, &mut rng).expect("species 1"); + assert_eq!(m.level, 7); + assert_eq!(m.hp, m.max_hp); + assert!(m.max_hp > 0); + // Levels 1, 1 and 7 are learnable at level 7; 13 and 20 are not. + let known: Vec = m.moves.iter().filter(|s| !s.empty()).map(|s| s.id).collect(); + assert_eq!(known, vec![10, 11, 12]); + } + + #[test] + fn only_the_last_four_learnset_moves_are_kept() { + let c = content(); + let mut rng = Rng::new(1); + let m = MonInstance::wild(&c, 1, 50, &mut rng).unwrap(); + let known: Vec = m.moves.iter().filter(|s| !s.empty()).map(|s| s.id).collect(); + assert_eq!(known, vec![11, 12, 13, 14], "the oldest move is dropped"); + } + + #[test] + fn an_unknown_species_yields_nothing() { + let c = content(); + let mut rng = Rng::new(1); + assert!(MonInstance::wild(&c, 999, 5, &mut rng).is_none()); + } + + #[test] + fn explicit_rosters_win_but_empty_ones_fall_back() { + let c = content(); + let m = MonInstance::with_moves(&c, 1, 10, &[13, 14, 0, 0], Dvs::perfect()).unwrap(); + let known: Vec = m.moves.iter().filter(|s| !s.empty()).map(|s| s.id).collect(); + assert_eq!(known, vec![13, 14]); + let m = MonInstance::with_moves(&c, 1, 10, &[0, 0, 0, 0], Dvs::perfect()).unwrap(); + assert!(!m.moves[0].empty(), "an empty roster falls back to the learnset"); + } + + #[test] + fn levelling_up_adds_the_hp_difference_without_healing() { + let c = content(); + let mut rng = Rng::new(2); + let mut m = MonInstance::wild(&c, 1, 5, &mut rng).unwrap(); + m.damage(m.max_hp / 2); + let hp_before = m.hp; + let max_before = m.max_hp; + let target = growth::exp_for_level(spec::growth::MEDIUM_SLOW, 10); + let gained = m.gain_exp(&c, target.saturating_sub(m.exp)); + assert!(gained > 0); + assert_eq!(m.level, 10); + assert!(m.max_hp > max_before); + assert_eq!(m.hp, hp_before + (m.max_hp - max_before), "gain, not a heal"); + assert!(m.hp < m.max_hp); + } + + #[test] + fn experience_is_capped_at_level_one_hundred() { + let c = content(); + let mut rng = Rng::new(3); + let mut m = MonInstance::wild(&c, 1, 99, &mut rng).unwrap(); + m.gain_exp(&c, u32::MAX); + assert_eq!(m.level, 100); + assert_eq!(m.exp, growth::exp_for_level(spec::growth::MEDIUM_SLOW, 100)); + // Another award cannot push it further. + assert_eq!(m.gain_exp(&c, 10_000), 0); + assert_eq!(m.level, 100); + } + + #[test] + fn damage_and_heal_stay_inside_the_hp_range() { + let c = content(); + let mut rng = Rng::new(4); + let mut m = MonInstance::wild(&c, 1, 20, &mut rng).unwrap(); + let max = m.max_hp; + assert_eq!(m.damage(max + 500), max, "damage is clamped to current HP"); + assert!(m.fainted()); + assert_eq!(m.heal(9999), max); + assert_eq!(m.hp, max); + assert_eq!(m.heal(10), 0, "already full"); + } + + #[test] + fn full_heal_restores_status_and_pp() { + let c = content(); + let mut rng = Rng::new(5); + let mut m = MonInstance::wild(&c, 1, 20, &mut rng).unwrap(); + m.damage(5); + m.status = spec::status::POISON; + m.moves[0].pp = 0; + m.heal_full(); + assert_eq!(m.hp, m.max_hp); + assert_eq!(m.status, spec::status::NONE); + assert_eq!(m.moves[0].pp, m.moves[0].pp_max); + } + + #[test] + fn learning_fills_slots_then_reports_full() { + let c = content(); + let mut m = MonInstance::with_moves(&c, 1, 5, &[10, 0, 0, 0], Dvs::default()).unwrap(); + assert!(m.learn(&c, 11)); + assert!(m.learn(&c, 12)); + assert!(m.learn(&c, 13)); + assert!(!m.learn(&c, 14), "no free slot"); + assert!(m.learn(&c, 10), "already known is a no-op success"); + m.replace_move(&c, 0, 14); + assert_eq!(m.moves[0].id, 14); + } + + #[test] + fn evolution_triggers_at_the_level_threshold() { + let c = content(); + let mut rng = Rng::new(6); + let m = MonInstance::wild(&c, 1, 15, &mut rng).unwrap(); + assert_eq!(m.evolution(&c), None); + let m = MonInstance::wild(&c, 1, 16, &mut rng).unwrap(); + assert_eq!(m.evolution(&c), Some(2)); + } + + #[test] + fn the_party_caps_at_six_and_tracks_health() { + let c = content(); + let mut rng = Rng::new(7); + let mut p = Party::default(); + for i in 0..spec::PARTY_MAX { + assert_eq!(p.add(MonInstance::wild(&c, 1, 5, &mut rng).unwrap()), Some(i)); + } + assert!(p.full()); + assert!(p.add(MonInstance::wild(&c, 1, 5, &mut rng).unwrap()).is_none()); + assert_eq!(p.first_healthy(), Some(0)); + p.mons[0].hp = 0; + assert_eq!(p.first_healthy(), Some(1)); + for m in p.mons.iter_mut() { + m.hp = 0; + } + assert!(p.wiped()); + p.heal_all(); + assert!(!p.wiped()); + } + + #[test] + fn an_empty_party_counts_as_wiped() { + assert!(Party::default().wiped()); + } + + #[test] + fn bag_stacks_merge_and_drain() { + let mut b = Bag::default(); + assert!(b.add(1, 3)); + assert!(b.add(1, 2)); + assert_eq!(b.count(1), 5); + assert_eq!(b.slots.len(), 1, "same item stacks"); + assert!(!b.take(1, 6), "cannot overdraw"); + assert!(b.take(1, 5)); + assert!(b.slots.is_empty(), "an empty stack is removed"); + assert!(!b.take(1, 1)); + } + + #[test] + fn bag_stacks_cap_at_99_and_the_bag_caps_on_distinct_items() { + let mut b = Bag::default(); + b.add(1, 99); + b.add(1, 50); + assert_eq!(b.count(1), 99); + for i in 2..=(spec::BAG_MAX as u16) { + assert!(b.add(i, 1)); + } + assert!(!b.add(999, 1), "no room for another distinct item"); + assert!(b.add(1, 1), "existing stacks still accept more"); + } + + #[test] + fn boxes_spill_into_the_next_box_when_full() { + let c = content(); + let mut rng = Rng::new(8); + let mut boxes = Boxes::new(); + for _ in 0..spec::BOX_SIZE { + assert_eq!(boxes.deposit(MonInstance::wild(&c, 1, 5, &mut rng).unwrap()), Some(0)); + } + assert_eq!(boxes.deposit(MonInstance::wild(&c, 1, 5, &mut rng).unwrap()), Some(1)); + assert_eq!(boxes.total(), spec::BOX_SIZE + 1); + assert!(boxes.withdraw(0, 0).is_some()); + assert_eq!(boxes.total(), spec::BOX_SIZE); + assert!(boxes.withdraw(0, 999).is_none()); + } + + #[test] + fn every_box_full_reports_failure() { + let c = content(); + let mut rng = Rng::new(9); + let mut boxes = Boxes::new(); + for _ in 0..(spec::BOX_COUNT * spec::BOX_SIZE) { + assert!(boxes.deposit(MonInstance::wild(&c, 1, 5, &mut rng).unwrap()).is_some()); + } + assert!(boxes.deposit(MonInstance::wild(&c, 1, 5, &mut rng).unwrap()).is_none()); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/mon/stats.rs b/engine/pocketmon/crates/pocketmon-core/src/mon/stats.rs new file mode 100644 index 00000000..869fe94b --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/mon/stats.rs @@ -0,0 +1,345 @@ +//! Stat calculation and the battle stat stages. +//! +//! Ported from upstream `src/pokemon/Stats.lua`, which documents the formula +//! as: +//! +//! ```text +//! stat = floor(((base + DV) * 2 + floor(ceil(sqrt(statExp)) / 4)) * level / 100) + 5 +//! HP adds level + 10 instead of 5. +//! ``` +//! +//! Two details are load-bearing and easy to get wrong: +//! - the stat-experience term is a **ceiling** square root capped at 255, +//! then quartered — not a floor; +//! - the HP DV is **derived** from the low bit of the other four DVs, it is +//! never rolled independently. +//! +//! Everything here is integer math (no `f32::sqrt`), so a PSP and an x86 host +//! produce identical stats. + +use crate::content::Species; +use crate::rng::Rng; +use crate::spec; + +/// The five battle stats. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Stats { + pub hp: u16, + pub attack: u16, + pub defense: u16, + pub speed: u16, + pub special: u16, +} + +impl Stats { + /// Read a stat by the same index the stage array uses. + pub fn get(&self, idx: usize) -> u16 { + match idx { + 0 => self.hp, + 1 => self.attack, + 2 => self.defense, + 3 => self.speed, + _ => self.special, + } + } +} + +/// Index into [`Stages`] / [`Stats::get`]. +pub mod stat { + pub const HP: usize = 0; + pub const ATTACK: usize = 1; + pub const DEFENSE: usize = 2; + pub const SPEED: usize = 3; + pub const SPECIAL: usize = 4; + pub const ACCURACY: usize = 5; + pub const EVASION: usize = 6; + pub const COUNT: usize = 7; +} + +/// Individual values, 0..=15 each. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Dvs { + pub attack: u8, + pub defense: u8, + pub speed: u8, + pub special: u8, +} + +impl Dvs { + /// The HP DV is assembled from the low bit of the other four — it has no + /// storage of its own. + pub fn hp(&self) -> u8 { + (self.attack & 1) << 3 | (self.defense & 1) << 2 | (self.speed & 1) << 1 | (self.special & 1) + } + + pub fn roll(rng: &mut Rng) -> Dvs { + Dvs { + attack: rng.range(0, 15) as u8, + defense: rng.range(0, 15) as u8, + speed: rng.range(0, 15) as u8, + special: rng.range(0, 15) as u8, + } + } + + /// Every DV maxed — what a scripted gift or a test fixture wants. + pub fn perfect() -> Dvs { + Dvs { attack: 15, defense: 15, speed: 15, special: 15 } + } + + pub fn get(&self, idx: usize) -> u8 { + match idx { + stat::HP => self.hp(), + stat::ATTACK => self.attack, + stat::DEFENSE => self.defense, + stat::SPEED => self.speed, + _ => self.special, + } + } +} + +/// Accumulated stat experience, one u16 per stat. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct StatExp { + pub hp: u16, + pub attack: u16, + pub defense: u16, + pub speed: u16, + pub special: u16, +} + +impl StatExp { + pub fn get(&self, idx: usize) -> u16 { + match idx { + stat::HP => self.hp, + stat::ATTACK => self.attack, + stat::DEFENSE => self.defense, + stat::SPEED => self.speed, + _ => self.special, + } + } + + /// Add a defeated creature's yield, saturating at the u16 ceiling. + pub fn add(&mut self, s: &Species) { + self.hp = self.hp.saturating_add(s.base_hp as u16); + self.attack = self.attack.saturating_add(s.base_atk as u16); + self.defense = self.defense.saturating_add(s.base_def as u16); + self.speed = self.speed.saturating_add(s.base_spd as u16); + self.special = self.special.saturating_add(s.base_spc as u16); + } +} + +/// Battle stat stages, -6..=+6, including accuracy and evasion. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Stages(pub [i8; stat::COUNT]); + +impl Default for Stages { + fn default() -> Self { + Stages([0; stat::COUNT]) + } +} + +impl Stages { + pub fn get(&self, idx: usize) -> i32 { + self.0.get(idx).copied().unwrap_or(0) as i32 + } + + /// Shift a stage, clamping to +-6. Returns false when it was already at + /// the cap (the "won't go any higher!" message). + pub fn shift(&mut self, idx: usize, delta: i32) -> bool { + let Some(slot) = self.0.get_mut(idx) else { + return false; + }; + let before = *slot as i32; + let after = (before + delta).clamp(spec::STAGE_MIN, spec::STAGE_MAX); + *slot = after as i8; + after != before + } + + pub fn reset(&mut self) { + self.0 = [0; stat::COUNT]; + } +} + +/// Smallest `b` with `b * b >= n`, capped at 255 — the ceiling square root the +/// stat-experience term needs, without touching a float. +pub fn ceil_sqrt_capped(n: u32) -> u32 { + if n == 0 { + return 0; + } + // Integer Newton's method, then a correction step for the ceiling. + let mut x = n.min(255 * 255); + let mut y = (x + 1) / 2; + while y < x { + x = y; + y = (x + n.min(255 * 255) / x) / 2; + } + // `x` is now floor(sqrt(n)); bump it when it does not reach n. + let r = if x * x < n.min(255 * 255) { x + 1 } else { x }; + r.min(255) +} + +/// One stat from base, DV, stat experience and level. +pub fn calc_one(base: u8, dv: u8, stat_exp: u16, level: u8, is_hp: bool) -> u16 { + let ev = ceil_sqrt_capped(stat_exp as u32) / 4; + let v = ((base as u32 + dv as u32) * 2 + ev) * level as u32 / 100; + let out = if is_hp { + v + level as u32 + 10 + } else { + v + 5 + }; + out.min(u16::MAX as u32) as u16 +} + +/// Every stat of a species at a level. +pub fn calc(species: &Species, level: u8, dvs: &Dvs, exp: &StatExp) -> Stats { + Stats { + hp: calc_one(species.base_hp, dvs.hp(), exp.hp, level, true), + attack: calc_one(species.base_atk, dvs.attack, exp.attack, level, false), + defense: calc_one(species.base_def, dvs.defense, exp.defense, level, false), + speed: calc_one(species.base_spd, dvs.speed, exp.speed, level, false), + special: calc_one(species.base_spc, dvs.special, exp.special, level, false), + } +} + +/// Apply a stat stage: `value * mult / 100`, clamped to 1..=999. +pub fn apply_stage(value: u16, stage: i32) -> u16 { + let idx = (stage.clamp(spec::STAGE_MIN, spec::STAGE_MAX) + 6) as usize; + let mult = spec::STAGE_MULT.get(idx).copied().unwrap_or(100); + let v = value as u32 * mult / 100; + v.clamp(1, spec::STAT_MAX) as u16 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn species(base: u8) -> Species { + Species { + base_hp: base, + base_atk: base, + base_def: base, + base_spd: base, + base_spc: base, + ..Default::default() + } + } + + #[test] + fn ceiling_sqrt_is_exact_at_the_boundaries() { + assert_eq!(ceil_sqrt_capped(0), 0); + assert_eq!(ceil_sqrt_capped(1), 1); + assert_eq!(ceil_sqrt_capped(2), 2, "ceiling, not floor"); + assert_eq!(ceil_sqrt_capped(4), 2); + assert_eq!(ceil_sqrt_capped(5), 3); + assert_eq!(ceil_sqrt_capped(9), 3); + assert_eq!(ceil_sqrt_capped(10), 4); + // Capped at 255 however big the input gets. + assert_eq!(ceil_sqrt_capped(65_025), 255); + assert_eq!(ceil_sqrt_capped(65_535), 255); + } + + #[test] + fn ceiling_sqrt_agrees_with_a_brute_force_scan() { + for n in 0..2000u32 { + let mut want = 0; + while want * want < n { + want += 1; + } + assert_eq!(ceil_sqrt_capped(n), want.min(255), "n = {n}"); + } + } + + #[test] + fn hp_dv_is_derived_from_the_other_four() { + let d = Dvs { attack: 15, defense: 15, speed: 15, special: 15 }; + assert_eq!(d.hp(), 15); + let d = Dvs { attack: 0, defense: 0, speed: 0, special: 0 }; + assert_eq!(d.hp(), 0); + // Only the low bit of each contributes, MSB-first in attack order. + let d = Dvs { attack: 1, defense: 0, speed: 0, special: 0 }; + assert_eq!(d.hp(), 8); + let d = Dvs { attack: 0, defense: 0, speed: 0, special: 1 }; + assert_eq!(d.hp(), 1); + let d = Dvs { attack: 14, defense: 15, speed: 14, special: 15 }; + assert_eq!(d.hp(), 0b0101); + } + + #[test] + fn level_one_and_level_hundred_match_the_formula() { + let s = species(100); + let dvs = Dvs::perfect(); + let exp = StatExp::default(); + // Level 100, base 100, DV 15, no stat exp: + // ((100 + 15) * 2 + 0) * 100 / 100 = 230; +5 = 235, HP +100+10 = 340 + let st = calc(&s, 100, &dvs, &exp); + assert_eq!(st.attack, 235); + assert_eq!(st.hp, 340); + // Level 1: 230 * 1 / 100 = 2; +5 = 7, HP 2 + 1 + 10 = 13 + let st = calc(&s, 1, &dvs, &exp); + assert_eq!(st.attack, 7); + assert_eq!(st.hp, 13); + } + + #[test] + fn stat_experience_adds_a_quartered_ceiling_root() { + let s = species(50); + let dvs = Dvs::default(); + // statExp 65025 -> ceil sqrt 255 -> /4 = 63 + let exp = StatExp { attack: 65_025, ..Default::default() }; + let with = calc(&s, 100, &dvs, &exp); + let without = calc(&s, 100, &dvs, &StatExp::default()); + assert_eq!(with.attack - without.attack, 63); + } + + #[test] + fn stats_grow_monotonically_with_level() { + let s = species(80); + let dvs = Dvs::perfect(); + let exp = StatExp::default(); + let mut last = calc(&s, 1, &dvs, &exp); + for lv in 2..=100u8 { + let now = calc(&s, lv, &dvs, &exp); + assert!(now.hp >= last.hp && now.attack >= last.attack, "level {lv} regressed"); + last = now; + } + } + + #[test] + fn stages_scale_and_clamp() { + assert_eq!(apply_stage(100, 0), 100); + assert_eq!(apply_stage(100, 1), 150); + assert_eq!(apply_stage(100, 2), 200); + assert_eq!(apply_stage(100, 6), 400); + assert_eq!(apply_stage(100, -1), 66); + assert_eq!(apply_stage(100, -6), 25); + // Out-of-range stages clamp instead of indexing out of the table. + assert_eq!(apply_stage(100, 99), 400); + assert_eq!(apply_stage(100, -99), 25); + // The result floor is 1 and the ceiling is 999. + assert_eq!(apply_stage(1, -6), 1); + assert_eq!(apply_stage(999, 6), spec::STAT_MAX as u16); + } + + #[test] + fn stage_shifts_report_whether_they_moved() { + let mut s = Stages::default(); + assert!(s.shift(stat::ATTACK, 2)); + assert_eq!(s.get(stat::ATTACK), 2); + assert!(s.shift(stat::ATTACK, 6)); + assert_eq!(s.get(stat::ATTACK), 6, "clamped at +6"); + assert!(!s.shift(stat::ATTACK, 1), "already capped"); + assert!(s.shift(stat::ATTACK, -12)); + assert_eq!(s.get(stat::ATTACK), -6); + assert!(!s.shift(stat::ATTACK, -1)); + s.reset(); + assert_eq!(s.get(stat::ATTACK), 0); + } + + #[test] + fn stat_exp_saturates_instead_of_wrapping() { + let mut e = StatExp { attack: u16::MAX - 1, ..Default::default() }; + let s = species(200); + e.add(&s); + assert_eq!(e.attack, u16::MAX); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/rng.rs b/engine/pocketmon/crates/pocketmon-core/src/rng.rs new file mode 100644 index 00000000..a7bcc2a6 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/rng.rs @@ -0,0 +1,178 @@ +//! Deterministic RNG. +//! +//! Every roll in the core — encounters, damage spread, crits, catch, wander +//! AI — comes from here, so a (seed, input tape) pair reproduces a run +//! byte-for-byte on any host (docs/DETERMINISM.md). No OS entropy, no float. +//! +//! xorshift64* : 64-bit state, one multiply, passes the smallcrush battery and +//! costs a handful of MIPS instructions per draw — the PSP budget matters here +//! because the wander AI rolls for every visible NPC every step. + +/// The core's only source of randomness. +#[derive(Clone, Copy, Debug)] +pub struct Rng { + state: u64, +} + +impl Default for Rng { + fn default() -> Self { + Rng::new(0x2545_f491_4f6c_dd1d) + } +} + +impl Rng { + /// Seed the generator. A zero seed is replaced (xorshift's fixed point). + pub fn new(seed: u64) -> Self { + Rng { + state: if seed == 0 { 0x9e37_79b9_7f4a_7c15 } else { seed }, + } + } + + /// Re-seed in place, keeping the zero-seed guard. + pub fn reseed(&mut self, seed: u64) { + *self = Rng::new(seed); + } + + /// The raw state, for save files and replay checkpoints. + pub fn state(&self) -> u64 { + self.state + } + + /// Restore a saved state verbatim (no zero guard: a save round-trip must + /// reproduce the exact stream, and `state` can never be 0 once seeded). + pub fn set_state(&mut self, state: u64) { + self.state = if state == 0 { 0x9e37_79b9_7f4a_7c15 } else { state }; + } + + /// Next raw 64 bits. + pub fn next_u64(&mut self) -> u64 { + let mut x = self.state; + x ^= x >> 12; + x ^= x << 25; + x ^= x >> 27; + self.state = x; + x.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + + /// Next 32 bits (the high half — the low bits of xorshift64* are weaker). + pub fn next_u32(&mut self) -> u32 { + (self.next_u64() >> 32) as u32 + } + + /// A byte in 0..=255 — the unit every ported Gen-1 roll is expressed in + /// (`rand(0, 255) < threshold`). + pub fn byte(&mut self) -> u32 { + self.next_u32() >> 24 + } + + /// Uniform in `lo..=hi`. Returns `lo` when the range is empty or inverted. + /// + /// Debiased by rejection: the naive `% range` skews low values whenever + /// the range does not divide 2^32, and a skewed damage spread is exactly + /// the kind of drift a formula test would not catch. + pub fn range(&mut self, lo: u32, hi: u32) -> u32 { + if hi <= lo { + return lo; + } + let span = hi - lo + 1; + if span == 0 { + return self.next_u32(); + } + let zone = u32::MAX - (u32::MAX % span) - (span - 1); + loop { + let v = self.next_u32(); + if v <= zone { + return lo + v % span; + } + } + } + + /// True with probability `num / den`. + pub fn chance(&mut self, num: u32, den: u32) -> bool { + if den == 0 { + return false; + } + self.range(0, den - 1) < num + } + + /// True with probability `percent / 100`. + pub fn percent(&mut self, percent: u32) -> bool { + self.chance(percent, 100) + } +} + +#[cfg(test)] +mod tests { + use super::Rng; + + #[test] + fn same_seed_same_stream() { + let mut a = Rng::new(42); + let mut b = Rng::new(42); + for _ in 0..1000 { + assert_eq!(a.next_u64(), b.next_u64()); + } + } + + #[test] + fn state_round_trips() { + let mut a = Rng::new(7); + for _ in 0..37 { + a.next_u64(); + } + let mut b = Rng::new(1); + b.set_state(a.state()); + for _ in 0..100 { + assert_eq!(a.next_u64(), b.next_u64()); + } + } + + #[test] + fn zero_seed_is_guarded() { + let mut r = Rng::new(0); + assert_ne!(r.next_u64(), 0); + } + + #[test] + fn bytes_span_the_full_range() { + let mut r = Rng::new(99); + let (mut lo, mut hi) = (255u32, 0u32); + for _ in 0..10_000 { + let b = r.byte(); + assert!(b < 256); + lo = lo.min(b); + hi = hi.max(b); + } + assert_eq!(lo, 0); + assert_eq!(hi, 255); + } + + #[test] + fn range_is_inclusive_and_bounded() { + let mut r = Rng::new(5); + let (mut lo, mut hi) = (u32::MAX, 0u32); + for _ in 0..20_000 { + let v = r.range(217, 255); + assert!((217..=255).contains(&v)); + lo = lo.min(v); + hi = hi.max(v); + } + assert_eq!((lo, hi), (217, 255)); + // degenerate ranges never spin + assert_eq!(r.range(9, 9), 9); + assert_eq!(r.range(9, 3), 9); + } + + #[test] + fn range_is_not_visibly_biased() { + // 3 does not divide 2^32; a naive `% 3` skews bucket 0 upward. + let mut r = Rng::new(0xfeed); + let mut counts = [0u32; 3]; + for _ in 0..30_000 { + counts[r.range(0, 2) as usize] += 1; + } + for c in counts { + assert!((9_400..10_600).contains(&c), "bucket skew: {counts:?}"); + } + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/save.rs b/engine/pocketmon/crates/pocketmon-core/src/save.rs new file mode 100644 index 00000000..24e41000 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/save.rs @@ -0,0 +1,639 @@ +//! Save and load. +//! +//! Ported from upstream `src/core/SaveData.lua`, with one deliberate change: +//! the upstream save is a serialized Lua table, which is convenient on a +//! desktop and unaffordable on a PSP. This is a flat little-endian binary blob +//! with a checksummed header, so loading is a linear scan with no parser. +//! +//! ```text +//! 0 u32 MAGIC 'MSAV' | 4 u16 VERSION | 6 u16 flags +//! 8 u32 byte length | 12 u32 FNV-1a over everything past byte 16 +//! 16 payload +//! ``` +//! +//! A save whose magic, version, length or checksum does not check out is +//! refused whole. Half-loading a corrupt save over a live game is the one +//! failure mode worse than losing the save. + +use alloc::vec::Vec; + +use crate::content::Content; +use crate::mon::{Bag, BagSlot, Boxes, Dvs, MonInstance, MoveSlot, StatExp}; +use crate::rng::Rng; +use crate::spec; +use crate::world::World; +use crate::PlayerState; + +/// FNV-1a over a byte slice. +pub fn checksum(bytes: &[u8]) -> u32 { + let mut h = spec::save::FNV1A_OFFSET_BASIS; + for &b in bytes { + h ^= b as u32; + h = h.wrapping_mul(spec::save::FNV1A_PRIME); + } + h +} + +// --------------------------------------------------------------------------- +// Writer +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct Writer { + out: Vec, +} + +impl Writer { + fn u8(&mut self, v: u8) { + self.out.push(v); + } + fn u16(&mut self, v: u16) { + self.out.extend_from_slice(&v.to_le_bytes()); + } + fn i16(&mut self, v: i16) { + self.out.extend_from_slice(&v.to_le_bytes()); + } + fn u32(&mut self, v: u32) { + self.out.extend_from_slice(&v.to_le_bytes()); + } + fn u64(&mut self, v: u64) { + self.out.extend_from_slice(&v.to_le_bytes()); + } + fn bytes(&mut self, v: &[u8]) { + self.out.extend_from_slice(v); + } + + fn mon(&mut self, m: &MonInstance) { + self.u16(m.species); + self.u8(m.level); + self.u32(m.exp); + self.u16(m.hp); + self.u16(m.max_hp); + self.u8(m.status); + self.u8(m.sleep); + self.u8(m.dvs.attack); + self.u8(m.dvs.defense); + self.u8(m.dvs.speed); + self.u8(m.dvs.special); + self.u16(m.stat_exp.hp); + self.u16(m.stat_exp.attack); + self.u16(m.stat_exp.defense); + self.u16(m.stat_exp.speed); + self.u16(m.stat_exp.special); + for slot in &m.moves { + self.u16(slot.id); + self.u8(slot.pp); + self.u8(slot.pp_max); + } + self.u16(m.nickname_key); + self.u16(m.original_trainer); + } +} + +// --------------------------------------------------------------------------- +// Reader +// --------------------------------------------------------------------------- + +struct Reader<'a> { + bytes: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(bytes: &'a [u8]) -> Self { + Reader { bytes, pos: 0 } + } + fn take(&mut self, n: usize) -> Option<&'a [u8]> { + let end = self.pos.checked_add(n)?; + let out = self.bytes.get(self.pos..end)?; + self.pos = end; + Some(out) + } + fn u8(&mut self) -> Option { + Some(self.take(1)?[0]) + } + fn u16(&mut self) -> Option { + let b = self.take(2)?; + Some(u16::from_le_bytes([b[0], b[1]])) + } + fn i16(&mut self) -> Option { + Some(self.u16()? as i16) + } + fn u32(&mut self) -> Option { + let b = self.take(4)?; + Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]])) + } + fn u64(&mut self) -> Option { + let b = self.take(8)?; + Some(u64::from_le_bytes([ + b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], + ])) + } + + fn mon(&mut self) -> Option { + let species = self.u16()?; + let level = self.u8()?; + let exp = self.u32()?; + let hp = self.u16()?; + let max_hp = self.u16()?; + let status = self.u8()?; + let sleep = self.u8()?; + let dvs = Dvs { + attack: self.u8()?, + defense: self.u8()?, + speed: self.u8()?, + special: self.u8()?, + }; + let stat_exp = StatExp { + hp: self.u16()?, + attack: self.u16()?, + defense: self.u16()?, + speed: self.u16()?, + special: self.u16()?, + }; + let mut moves = [MoveSlot::default(); spec::MOVES_MAX]; + for slot in moves.iter_mut() { + slot.id = self.u16()?; + slot.pp = self.u8()?; + slot.pp_max = self.u8()?; + } + let nickname_key = self.u16()?; + let original_trainer = self.u16()?; + Some(MonInstance { + species, + level, + exp, + // A save claiming more HP than the maximum is clamped rather than + // rejected: it is recoverable, and the alternative is a lost game. + hp: hp.min(max_hp), + max_hp, + status, + sleep, + dvs, + stat_exp, + moves, + nickname_key, + original_trainer, + }) + } +} + +// --------------------------------------------------------------------------- +// The save payload +// --------------------------------------------------------------------------- + +/// Everything a save round-trips. +pub struct Snapshot<'a> { + pub player: &'a PlayerState, + pub world: &'a World, + pub content: &'a Content, + pub rng: &'a Rng, +} + +/// Serialize a snapshot. +pub fn save(snap: Snapshot) -> Vec { + let mut w = Writer::default(); + let p = snap.player; + + // --- player --- + w.u16(p.name_key); + w.u32(p.money); + w.u8(p.badges); + // --- world position --- + w.u16(snap.world.map_id); + w.i16(snap.world.player().cx as i16); + w.i16(snap.world.player().cy as i16); + w.u8(snap.world.player().dir); + w.u16(snap.world.last_outdoor); + w.u32(snap.world.steps); + w.u8(snap.world.surfing as u8); + // --- rng --- + w.u64(snap.rng.state()); + + // --- flags --- + w.u16(p.flags.len() as u16); + w.bytes(&p.flags); + // --- dex --- + w.u16(p.dex_seen.len() as u16); + w.bytes(&p.dex_seen); + w.u16(p.dex_owned.len() as u16); + w.bytes(&p.dex_owned); + + // --- party --- + w.u8(p.party.len() as u8); + for m in &p.party.mons { + w.mon(m); + } + // --- boxes --- + w.u8(p.boxes.boxes.len() as u8); + w.u8(p.boxes.current); + for b in &p.boxes.boxes { + w.u8(b.len() as u8); + for m in b { + w.mon(m); + } + } + // --- bag --- + w.u8(p.bag.slots.len() as u8); + for s in &p.bag.slots { + w.u16(s.item); + w.u8(s.qty); + } + // --- runtime block overrides (a door a script opened stays open) --- + let overrides = snap.content.block_overrides.len().min(u16::MAX as usize); + w.u16(overrides as u16); + for (&k, &v) in snap.content.block_overrides.iter().take(overrides) { + w.u32(k); + w.u8(v); + } + + let payload = w.out; + let mut out = Vec::with_capacity(spec::save::HEADER_SIZE + payload.len()); + out.extend_from_slice(&spec::save::MAGIC.to_le_bytes()); + out.extend_from_slice(&spec::save::VERSION.to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); // flags + out.extend_from_slice(&((spec::save::HEADER_SIZE + payload.len()) as u32).to_le_bytes()); + out.extend_from_slice(&checksum(&payload).to_le_bytes()); + out.extend_from_slice(&payload); + out +} + +/// What a successful load produced. The caller installs it wholesale, so a +/// failed load cannot leave the game half-updated. +pub struct Loaded { + pub player: PlayerState, + pub map: u16, + pub cx: i32, + pub cy: i32, + pub dir: u8, + pub last_outdoor: u16, + pub steps: u32, + pub surfing: bool, + pub rng_state: u64, + pub block_overrides: Vec<(u32, u8)>, +} + +/// Parse a save blob. Returns None for anything that does not check out. +pub fn load(bytes: &[u8]) -> Option { + if bytes.len() < spec::save::HEADER_SIZE { + return None; + } + let magic = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); + if magic != spec::save::MAGIC { + return None; + } + let version = u16::from_le_bytes([bytes[4], bytes[5]]); + if version != spec::save::VERSION { + return None; + } + let len = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize; + if len != bytes.len() { + return None; + } + let want = u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]); + let payload = bytes.get(spec::save::HEADER_SIZE..)?; + if checksum(payload) != want { + return None; + } + + let mut r = Reader::new(payload); + let mut player = PlayerState::new(); + player.name_key = r.u16()?; + player.money = r.u32()?; + player.badges = r.u8()?; + + let map = r.u16()?; + let cx = r.i16()? as i32; + let cy = r.i16()? as i32; + let dir = r.u8()?; + let last_outdoor = r.u16()?; + let steps = r.u32()?; + let surfing = r.u8()? != 0; + let rng_state = r.u64()?; + + let flag_bytes = r.u16()? as usize; + player.flags = r.take(flag_bytes)?.to_vec(); + // Keep the flag array at the size the core indexes, whatever the save says. + player.flags.resize(spec::FLAG_COUNT / 8, 0); + + let seen = r.u16()? as usize; + player.dex_seen = r.take(seen)?.to_vec(); + let owned = r.u16()? as usize; + player.dex_owned = r.take(owned)?.to_vec(); + + let party_len = r.u8()? as usize; + if party_len > spec::PARTY_MAX { + return None; + } + for _ in 0..party_len { + player.party.mons.push(r.mon()?); + } + + let box_count = r.u8()? as usize; + if box_count > spec::BOX_COUNT { + return None; + } + player.boxes = Boxes::new(); + player.boxes.current = r.u8()?; + player.boxes.boxes.clear(); + for _ in 0..box_count { + let n = r.u8()? as usize; + if n > spec::BOX_SIZE { + return None; + } + let mut b = Vec::with_capacity(n); + for _ in 0..n { + b.push(r.mon()?); + } + player.boxes.boxes.push(b); + } + + let bag_len = r.u8()? as usize; + if bag_len > spec::BAG_MAX { + return None; + } + player.bag = Bag::default(); + for _ in 0..bag_len { + let item = r.u16()?; + let qty = r.u8()?; + player.bag.slots.push(BagSlot { item, qty }); + } + + let override_count = r.u16()? as usize; + let mut block_overrides = Vec::with_capacity(override_count); + for _ in 0..override_count { + let k = r.u32()?; + let v = r.u8()?; + block_overrides.push((k, v)); + } + + Some(Loaded { + player, + map, + cx, + cy, + dir, + last_outdoor, + steps, + surfing, + rng_state, + block_overrides, + }) +} + +/// Rebuild derived stats after a load: max HP depends on content that may have +/// changed since the save was written (a mod rebalanced a species, say). +/// Current HP is preserved but never allowed above the new maximum. +pub fn rehydrate(player: &mut PlayerState, content: &Content) { + let fix = |m: &mut MonInstance| { + let hp = m.hp; + m.max_hp = 0; + m.recalc(content); + m.hp = hp.min(m.max_hp); + }; + for m in player.party.mons.iter_mut() { + fix(m); + } + for b in player.boxes.boxes.iter_mut() { + for m in b.iter_mut() { + fix(m); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::{MapDef, Species, Tileset}; + use alloc::vec; + + fn content() -> Content { + let mut c = Content::new(); + let mut behavior = [spec::cell::WALL; spec::TILE_BEHAVIOR_BYTES]; + behavior[1] = spec::cell::FLOOR; + c.tilesets.push(Tileset { blocks: vec![[1u8; 16]], behavior }); + c.maps.insert( + 1, + MapDef { + id: 1, + width: 4, + height: 4, + tileset: 0, + blocks: vec![0; 16], + conn: [-1; 4], + ..Default::default() + }, + ); + c.species.insert( + 1, + Species { + id: 1, + base_hp: 45, + base_atk: 49, + base_def: 49, + base_spd: 45, + base_spc: 65, + ..Default::default() + }, + ); + c + } + + /// A game state with something in every container. + fn populated() -> (PlayerState, World, Content, Rng) { + let c = content(); + let mut p = PlayerState::new(); + let mut rng = Rng::new(0xfeed); + p.name_key = 3; + p.money = 12_345; + p.badges = 0b101; + p.set_flag(7, true); + p.set_flag(300, true); + p.own(1); + p.see(9); + p.bag.add(4, 5); + p.bag.add(9, 1); + for _ in 0..3 { + p.party.add(MonInstance::wild(&c, 1, 12, &mut rng).unwrap()); + } + p.party.mons[1].damage(7); + p.party.mons[2].status = spec::status::POISON; + p.boxes = Boxes::new(); + p.boxes.deposit(MonInstance::wild(&c, 1, 3, &mut rng).unwrap()); + + let mut w = World::new(); + let flags = p.flags.clone(); + w.enter_map(&c, &flags, 1, 3, 2, spec::dir::LEFT); + w.steps = 999; + (p, w, c, rng) + } + + #[test] + fn a_save_round_trips_every_container() { + let (p, w, c, rng) = populated(); + let blob = save(Snapshot { player: &p, world: &w, content: &c, rng: &rng }); + let back = load(&blob).expect("valid save"); + + assert_eq!(back.player.name_key, p.name_key); + assert_eq!(back.player.money, p.money); + assert_eq!(back.player.badges, p.badges); + assert!(back.player.flag(7) && back.player.flag(300)); + assert!(!back.player.flag(8)); + assert!(back.player.owned(1) && back.player.seen(9)); + assert_eq!(back.player.bag.count(4), 5); + assert_eq!(back.player.bag.count(9), 1); + assert_eq!(back.player.party.len(), 3); + assert_eq!(back.player.party.mons, p.party.mons); + assert_eq!(back.player.boxes.total(), 1); + assert_eq!(back.map, 1); + assert_eq!((back.cx, back.cy, back.dir), (3, 2, spec::dir::LEFT)); + assert_eq!(back.steps, 999); + assert_eq!(back.rng_state, rng.state()); + } + + #[test] + fn the_rng_stream_continues_exactly_where_it_left_off() { + let (p, w, c, rng) = populated(); + let blob = save(Snapshot { player: &p, world: &w, content: &c, rng: &rng }); + let back = load(&blob).unwrap(); + + let mut original = rng; + let mut restored = Rng::new(1); + restored.set_state(back.rng_state); + for _ in 0..100 { + assert_eq!(original.next_u64(), restored.next_u64()); + } + } + + #[test] + fn a_corrupt_byte_is_caught_by_the_checksum() { + let (p, w, c, rng) = populated(); + let mut blob = save(Snapshot { player: &p, world: &w, content: &c, rng: &rng }); + assert!(load(&blob).is_some()); + let last = blob.len() - 1; + blob[last] ^= 0xff; + assert!(load(&blob).is_none(), "a flipped payload byte must be refused"); + } + + #[test] + fn byte_flips_across_the_whole_payload_are_detected() { + let (p, w, c, rng) = populated(); + let blob = save(Snapshot { player: &p, world: &w, content: &c, rng: &rng }); + // Sampled across the payload: enough to catch a checksum that only + // covers a prefix. + for i in (spec::save::HEADER_SIZE..blob.len()).step_by(7) { + let mut bad = blob.clone(); + bad[i] ^= 0x01; + assert!(load(&bad).is_none(), "byte {i} slipped through"); + } + } + + #[test] + fn a_wrong_magic_version_or_length_is_refused() { + let (p, w, c, rng) = populated(); + let good = save(Snapshot { player: &p, world: &w, content: &c, rng: &rng }); + + let mut bad = good.clone(); + bad[0] ^= 0xff; + assert!(load(&bad).is_none(), "magic"); + + let mut bad = good.clone(); + bad[4] = 99; + assert!(load(&bad).is_none(), "version"); + + let mut bad = good.clone(); + bad[8] = bad[8].wrapping_add(1); + assert!(load(&bad).is_none(), "length"); + + assert!(load(&[]).is_none()); + assert!(load(&good[..8]).is_none(), "truncated header"); + } + + #[test] + fn a_truncated_payload_is_refused_rather_than_partially_read() { + let (p, w, c, rng) = populated(); + let good = save(Snapshot { player: &p, world: &w, content: &c, rng: &rng }); + for cut in [20usize, 40, 60, good.len() - 4] { + let mut bad = good[..cut].to_vec(); + // Fix the length field so it is the payload, not the header, that + // is short — otherwise the length check would catch it first. + let n = bad.len() as u32; + bad[8..12].copy_from_slice(&n.to_le_bytes()); + let sum = checksum(&bad[spec::save::HEADER_SIZE..]); + bad[12..16].copy_from_slice(&sum.to_le_bytes()); + assert!(load(&bad).is_none(), "cut at {cut}"); + } + } + + #[test] + fn an_impossible_party_size_is_rejected() { + let (p, w, c, rng) = populated(); + let mut blob = save(Snapshot { player: &p, world: &w, content: &c, rng: &rng }); + // Walk the payload the way the loader does to find the party count, + // rather than hard-coding an offset that a format change would rot. + let head = 2 + 4 + 1 + 2 + 2 + 2 + 1 + 2 + 4 + 1 + 8; + let flags_at = spec::save::HEADER_SIZE + head; + let flag_len = u16::from_le_bytes([blob[flags_at], blob[flags_at + 1]]) as usize; + let seen_at = flags_at + 2 + flag_len; + let seen_len = u16::from_le_bytes([blob[seen_at], blob[seen_at + 1]]) as usize; + let owned_at = seen_at + 2 + seen_len; + let owned_len = u16::from_le_bytes([blob[owned_at], blob[owned_at + 1]]) as usize; + let party_at = owned_at + 2 + owned_len; + assert_eq!(blob[party_at], 3, "found the party count"); + + blob[party_at] = 99; + // Re-checksum so it is the party bound, not the checksum, that rejects it. + let sum = checksum(&blob[spec::save::HEADER_SIZE..]); + blob[12..16].copy_from_slice(&sum.to_le_bytes()); + assert!(load(&blob).is_none()); + } + + #[test] + fn rehydrate_never_lets_hp_exceed_the_maximum() { + let c = content(); + let mut p = PlayerState::new(); + let mut rng = Rng::new(5); + p.party.add(MonInstance::wild(&c, 1, 10, &mut rng).unwrap()); + let w = World::new(); + let blob = save(Snapshot { player: &p, world: &w, content: &c, rng: &rng }); + let mut back = load(&blob).unwrap(); + // Pretend the save came from content with a much beefier species. + back.player.party.mons[0].max_hp = 999; + back.player.party.mons[0].hp = 999; + rehydrate(&mut back.player, &c); + let m = &back.player.party.mons[0]; + assert!(m.hp <= m.max_hp); + assert!(m.max_hp > 0); + } + + #[test] + fn block_overrides_survive_the_round_trip() { + let (p, w, mut c, rng) = populated(); + c.set_block(1, 2, 1, 9); + assert_eq!(c.block_at(c.map_of(1).unwrap(), 2, 1), 9); + let blob = save(Snapshot { player: &p, world: &w, content: &c, rng: &rng }); + let back = load(&blob).unwrap(); + assert_eq!(back.block_overrides.len(), 1); + assert_eq!(back.block_overrides[0].1, 9); + } + + #[test] + fn an_empty_game_saves_and_loads() { + let c = content(); + let p = PlayerState::new(); + let w = World::new(); + let rng = Rng::new(1); + let blob = save(Snapshot { player: &p, world: &w, content: &c, rng: &rng }); + let back = load(&blob).expect("an empty game is still a valid save"); + assert_eq!(back.player.party.len(), 0); + assert_eq!(back.player.money, 0); + } + + #[test] + fn saves_are_byte_identical_for_identical_states() { + let (p, w, c, rng) = populated(); + let a = save(Snapshot { player: &p, world: &w, content: &c, rng: &rng }); + let b = save(Snapshot { player: &p, world: &w, content: &c, rng: &rng }); + assert_eq!(a, b, "serialization must be deterministic"); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/scene.rs b/engine/pocketmon/crates/pocketmon-core/src/scene.rs new file mode 100644 index 00000000..d22d552d --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/scene.rs @@ -0,0 +1,547 @@ +//! The scene builder: core state -> [`MonDrawList`]. +//! +//! Ported from upstream `src/render/TileRenderer.lua`, `SpriteRenderer.lua`, +//! `Camera.lua` and the battle-screen half of `BattleState.lua`. +//! +//! Everything here is per-frame work over per-entity data, which is exactly +//! what docs/MON.md §2 says must stay in Rust: a 30x17 tile view is ~500 quads +//! a frame, and pushing those across the QuickJS boundary would eat the PSP +//! frame budget several times over. The guest never sees a tile. +//! +//! ## Atlas layout +//! +//! The cooker packs art into CLUT8 pages of `spec::PAGE_PX` square. Three +//! conventions, fixed here and mirrored in `apps/mon/cook.ts`: +//! +//! | page | contents | cell | +//! | --- | --- | --- | +//! | 0 | terrain tiles, 32 per row | 8x8 | +//! | 1 | actor walk sheets, one row per actor, 12 poses | 16x16 | +//! | 2+ | creature portraits, 4x4 per page | 64x64 | +//! +//! An actor's pose is `dir * 3 + frame`, so a row is exactly 12 cells wide and +//! the sheet stays legible in an image viewer — which matters more than +//! squeezing the last four cells out of the row. + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use crate::content::Content; +use crate::draw::{abgr, clamp_i16, rgb, MonDrawList, Quad}; +use crate::spec; +use crate::text; +use crate::Game; + +/// Atlas page holding the 8x8 terrain tiles. +pub const TILE_PAGE: u8 = 0; +/// Atlas page holding the 16x16 actor walk sheets. +pub const ACTOR_PAGE: u8 = 1; +/// First atlas page holding 64x64 creature portraits. +pub const PORTRAIT_PAGE: u8 = 2; +/// Portrait cell edge. +pub const PORTRAIT_PX: u16 = 64; +/// Actor cell edge. +pub const ACTOR_PX: u16 = 16; +/// Poses per actor row: 4 directions x 3 walk frames. +pub const POSES: u16 = 12; + +/// Interface colors — a four-tone set in the handheld spirit, but ours. +pub const INK: u32 = rgb(0x18, 0x1c, 0x24); +pub const PAPER: u32 = rgb(0xf4, 0xf2, 0xe4); +pub const SHADE: u32 = rgb(0x8a, 0x92, 0x86); +pub const HP_GREEN: u32 = rgb(0x50, 0xb0, 0x50); +pub const HP_AMBER: u32 = rgb(0xd8, 0xa8, 0x30); +pub const HP_RED: u32 = rgb(0xc8, 0x48, 0x40); + +/// Height of the dialogue box, in logical pixels. +pub const BOX_H: i32 = 46; + +// --------------------------------------------------------------------------- +// Atlas helpers +// --------------------------------------------------------------------------- + +/// Texel origin of terrain tile `id` on [`TILE_PAGE`]. +pub fn tile_uv(id: u8) -> (u16, u16) { + let per_row = spec::PAGE_PX as u16 / spec::TILE_PX as u16; + let i = id as u16; + ( + (i % per_row) * spec::TILE_PX as u16, + (i / per_row) * spec::TILE_PX as u16, + ) +} + +/// Texel origin of an actor pose on [`ACTOR_PAGE`]. +pub fn actor_uv(sprite: u8, dir: u8, frame: u8) -> (u16, u16) { + let pose = (dir.min(3) as u16) * 3 + frame.min(2) as u16; + let row = (sprite as u16).min(spec::PAGE_PX as u16 / ACTOR_PX - 1); + (pose.min(POSES - 1) * ACTOR_PX, row * ACTOR_PX) +} + +/// Page and texel origin of a creature portrait cell. +pub fn portrait_uv(index: u8) -> (u8, u16, u16) { + let per_page = 16u16; // 4 x 4 + let i = index as u16; + let page = (PORTRAIT_PAGE as u16 + i / per_page).min(spec::PAGE_MAX as u16 - 1) as u8; + let within = i % per_page; + (page, (within % 4) * PORTRAIT_PX, (within / 4) * PORTRAIT_PX) +} + +// --------------------------------------------------------------------------- +// Text +// --------------------------------------------------------------------------- + +/// Draw a string at a logical position. Returns the advance width. +/// +/// `limit` truncates the run to that many characters (the typewriter); pass +/// `usize::MAX` for the whole string. +pub fn draw_text_run( + draw: &mut MonDrawList, + content: &Content, + x: i32, + y: i32, + s: &str, + limit: usize, +) -> i32 { + let mut pen = x; + for (i, ch) in s.chars().enumerate() { + if i >= limit { + break; + } + if let Some(g) = content.glyph(ch as u32) { + draw.quad(Quad { + x: clamp_i16(pen), + y: clamp_i16(y), + u: g.u, + v: g.v, + w: g.w, + h: g.h, + page: content.font_page, + flags: 0, + tint: spec::TINT_NONE, + }); + } + pen += text::advance(content, ch); + } + pen - x +} + +// --------------------------------------------------------------------------- +// Overworld +// --------------------------------------------------------------------------- + +/// Draw the map, then the actors. +pub fn draw_world(g: &mut Game) { + let Some(map) = g.content.map_of(g.world.map_id).cloned() else { + // No map loaded yet: a flat backdrop beats an undefined screen. + g.draw.rect(0, 0, spec::VIEW_W, spec::VIEW_H, INK); + return; + }; + + let cam_x = g.world.cam_x; + let cam_y = g.world.cam_y; + + // --- terrain ----------------------------------------------------------- + // One extra tile on each axis so a half-scrolled edge is still covered. + let t0x = crate::world::map::div_floor(cam_x, spec::TILE_PX); + let t0y = crate::world::map::div_floor(cam_y, spec::TILE_PX); + let cols = spec::VIEW_W / spec::TILE_PX + 2; + let rows = spec::VIEW_H / spec::TILE_PX + 2; + for ty in 0..rows { + for tx in 0..cols { + let wx = t0x + tx; + let wy = t0y + ty; + let id = crate::world::map::tile_at(&g.content, &map, wx, wy); + let (u, v) = tile_uv(id); + g.draw.tile( + wx * spec::TILE_PX - cam_x, + wy * spec::TILE_PX - cam_y, + u, + v, + TILE_PAGE, + 0, + ); + } + } + + // --- actors ------------------------------------------------------------ + // Painter's order by foot position, so someone standing lower overlaps + // someone standing higher — the classic top-down depth cue. + let mut order: [(i32, usize); spec::ACTORS_MAX] = [(0, 0); spec::ACTORS_MAX]; + let mut n = 0; + for (i, a) in g.world.actors.iter().enumerate() { + if a.active && a.visible { + order[n] = (a.pixel_pos().1, i); + n += 1; + } + } + order[..n].sort_unstable(); + + for &(_, i) in &order[..n] { + let a = &g.world.actors[i]; + let (px, py) = a.pixel_pos(); + let (u, v) = actor_uv(a.sprite, a.dir, a.anim_frame()); + let (x, y) = (px - cam_x, py - cam_y + a.hop_arc()); + g.draw.quad(Quad { + x: clamp_i16(x), + y: clamp_i16(y), + u, + v, + w: ACTOR_PX as u8, + h: ACTOR_PX as u8, + page: ACTOR_PAGE, + flags: 0, + tint: spec::TINT_NONE, + }); + } +} + +// --------------------------------------------------------------------------- +// Battle +// --------------------------------------------------------------------------- + +/// Draw the battle screen: both creatures, both status panels, and whichever +/// prompt the current phase calls for. +pub fn draw_battle(g: &mut Game) { + let Some(battle) = g.battle.as_ref() else { return }; + + g.draw.rect(0, 0, spec::VIEW_W, spec::VIEW_H, PAPER); + + // Foe upper right, player lower left — the genre's diagonal. + let foe_portrait = g + .content + .species_of(battle.foe.mon.species) + .map(|s| s.front_tile) + .unwrap_or(0); + let own_portrait = g + .content + .species_of(battle.player.mon.species) + .map(|s| s.back_tile) + .unwrap_or(0); + let foe_name = battle.foe.name(&g.content); + let foe_hp = (battle.foe.mon.hp, battle.foe.mon.max_hp); + let foe_level = battle.foe.mon.level; + let own_name = battle.player.name(&g.content); + let own_hp = (battle.player.mon.hp, battle.player.mon.max_hp); + let own_level = battle.player.mon.level; + let phase = battle.phase; + + let (fp, fu, fv) = portrait_uv(foe_portrait); + g.draw.quad(Quad { + x: clamp_i16(spec::VIEW_W - 78), + y: 6, + u: fu, + v: fv, + w: PORTRAIT_PX as u8, + h: PORTRAIT_PX as u8, + page: fp, + flags: 0, + tint: spec::TINT_NONE, + }); + + let (pp, pu, pv) = portrait_uv(own_portrait); + g.draw.quad(Quad { + x: 14, + y: clamp_i16(spec::VIEW_H - BOX_H - 62), + u: pu, + v: pv, + w: PORTRAIT_PX as u8, + h: PORTRAIT_PX as u8, + page: pp, + flags: 0, + tint: spec::TINT_NONE, + }); + + draw_status_panel(g, 6, 8, &foe_name, foe_level, foe_hp, false); + draw_status_panel( + g, + spec::VIEW_W - 110, + spec::VIEW_H - BOX_H - 32, + &own_name, + own_level, + own_hp, + true, + ); + + match phase { + spec::phase::CHOOSE_ACTION => draw_action_menu(g), + spec::phase::CHOOSE_MOVE => draw_move_menu(g), + _ => draw_battle_message(g), + } +} + +fn draw_status_panel( + g: &mut Game, + x: i32, + y: i32, + name: &str, + level: u8, + hp: (u16, u16), + show_numbers: bool, +) { + let w = 104; + let h = if show_numbers { 30 } else { 24 }; + g.draw.frame(x, y, w, h, PAPER, INK); + draw_text_run(&mut g.draw, &g.content, x + 4, y + 3, name, usize::MAX); + + let mut lv = String::from(":L"); + lv.push_str(&level.to_string()); + draw_text_run(&mut g.draw, &g.content, x + w - 32, y + 3, &lv, usize::MAX); + + // HP bar: a 64 px track colored by the remaining fraction. + let track_x = x + 22; + let track_y = y + 14; + let track_w = 64; + g.draw.rect(track_x - 1, track_y - 1, track_w + 2, 6, INK); + g.draw.rect(track_x, track_y, track_w, 4, SHADE); + let (cur, max) = (hp.0 as i32, hp.1.max(1) as i32); + let filled = (track_w * cur / max).clamp(0, track_w); + let color = if cur * 2 > max { + HP_GREEN + } else if cur * 4 > max { + HP_AMBER + } else { + HP_RED + }; + if filled > 0 { + g.draw.rect(track_x, track_y, filled, 4, color); + } + draw_text_run(&mut g.draw, &g.content, x + 4, track_y - 2, "HP", usize::MAX); + + if show_numbers { + let mut s = text::pad_num(cur as u32, 3); + s.push('/'); + s.push_str(&text::pad_num(max as u32, 3)); + draw_text_run(&mut g.draw, &g.content, x + w - 62, y + 20, &s, usize::MAX); + } +} + +fn draw_battle_message(g: &mut Game) { + let y = spec::VIEW_H - BOX_H; + g.draw.frame(0, y, spec::VIEW_W, BOX_H, PAPER, INK); + let msg = g + .battle + .as_ref() + .and_then(|b| b.message()) + .unwrap_or("") + .to_string(); + if !msg.is_empty() { + draw_text_run(&mut g.draw, &g.content, 8, y + 8, &msg, usize::MAX); + } +} + +fn draw_action_menu(g: &mut Game) { + let y = spec::VIEW_H - BOX_H; + g.draw.frame(0, y, spec::VIEW_W, BOX_H, PAPER, INK); + let name = g + .battle + .as_ref() + .map(|b| b.player.name(&g.content)) + .unwrap_or_default(); + let mut prompt = String::from("What will "); + prompt.push_str(&name); + prompt.push_str(" do?"); + draw_text_run(&mut g.draw, &g.content, 8, y + 8, &prompt, usize::MAX); + + const LABELS: [&str; 4] = ["FIGHT", "ITEM", "MON", "RUN"]; + let bx = spec::VIEW_W - 96; + g.draw.frame(bx, y + 4, 92, BOX_H - 8, PAPER, INK); + let cursor = g.menu_cursor as usize; + for (i, label) in LABELS.iter().enumerate() { + let col = (i % 2) as i32; + let row = (i / 2) as i32; + let lx = bx + 12 + col * 44; + let ly = y + 10 + row * 14; + if i == cursor { + draw_text_run(&mut g.draw, &g.content, lx - 9, ly, ">", usize::MAX); + } + draw_text_run(&mut g.draw, &g.content, lx, ly, label, usize::MAX); + } +} + +fn draw_move_menu(g: &mut Game) { + let y = spec::VIEW_H - BOX_H; + g.draw.frame(0, y, spec::VIEW_W, BOX_H, PAPER, INK); + let cursor = g.menu_cursor as usize; + + // Collect first: drawing needs `&mut g.draw` while these read `g.battle`. + let mut rows: Vec<(String, u8, u8)> = Vec::with_capacity(spec::MOVES_MAX); + if let Some(battle) = g.battle.as_ref() { + for slot in battle.player.mon.moves.iter() { + if slot.empty() { + rows.push((String::from("-"), 0, 0)); + continue; + } + let name = g + .content + .move_of(slot.id) + .map(|m| String::from(g.content.string(m.name_key))) + .unwrap_or_else(|| String::from("???")); + rows.push((name, slot.pp, slot.pp_max)); + } + } + + for (i, (name, pp, pp_max)) in rows.iter().enumerate() { + let ly = y + 6 + i as i32 * 10; + if i == cursor { + draw_text_run(&mut g.draw, &g.content, 4, ly, ">", usize::MAX); + } + draw_text_run(&mut g.draw, &g.content, 14, ly, name, usize::MAX); + if *pp_max > 0 { + let mut s = String::from("PP "); + s.push_str(&text::pad_num(*pp as u32, 2)); + s.push('/'); + s.push_str(&text::pad_num(*pp_max as u32, 2)); + draw_text_run(&mut g.draw, &g.content, spec::VIEW_W - 70, ly, &s, usize::MAX); + } + } +} + +// --------------------------------------------------------------------------- +// Overlays +// --------------------------------------------------------------------------- + +/// The dialogue box, its typewriter, and any choice attached to it. +pub fn draw_text(g: &mut Game) { + if !g.text.active() { + return; + } + // The battle draws its own message box; a guest textbox over it would be + // two boxes deep. + if g.battle.is_some() { + return; + } + let y = spec::VIEW_H - BOX_H; + g.draw.frame(0, y, spec::VIEW_W, BOX_H, PAPER, INK); + + let Some(lines) = g.text.current().map(|p| p.lines.clone()) else { + return; + }; + let mut budget = g.text.revealed(); + for (i, line) in lines.iter().enumerate() { + let take = budget.min(line.chars().count()); + draw_text_run(&mut g.draw, &g.content, 8, y + 6 + i as i32 * 12, line, take); + budget -= take; + if budget == 0 { + break; + } + } + + // The "more" caret, once the page is fully revealed. + if g.text.page_done && g.text.choice().is_none() && (g.tick_count / 20) % 2 == 0 { + draw_text_run( + &mut g.draw, + &g.content, + spec::VIEW_W - 16, + y + BOX_H - 14, + "\u{25bc}", + usize::MAX, + ); + } + + // Choice box, anchored above the dialogue. + let choice = g + .text + .choice() + .map(|c| (c.options.clone(), c.cursor as usize)); + if let Some((options, cursor)) = choice { + let w = 72; + let h = options.len() as i32 * 12 + 8; + let bx = spec::VIEW_W - w - 6; + let by = y - h - 4; + g.draw.frame(bx, by, w, h, PAPER, INK); + for (i, opt) in options.iter().enumerate() { + let ly = by + 4 + i as i32 * 12; + if i == cursor { + draw_text_run(&mut g.draw, &g.content, bx + 4, ly, ">", usize::MAX); + } + draw_text_run(&mut g.draw, &g.content, bx + 14, ly, opt, usize::MAX); + } + } +} + +/// The warp fade, drawn last so it covers everything. +pub fn draw_fade(g: &mut Game) { + let alpha = g.world.fade.alpha(); + if alpha == 0 { + return; + } + g.draw + .rect(0, 0, spec::VIEW_W, spec::VIEW_H, abgr(0, 0, 0, alpha.min(255) as u8)); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tile_uv_walks_the_page_in_rows_of_thirty_two() { + assert_eq!(tile_uv(0), (0, 0)); + assert_eq!(tile_uv(1), (8, 0)); + assert_eq!(tile_uv(31), (248, 0)); + assert_eq!(tile_uv(32), (0, 8)); + assert_eq!(tile_uv(255), (248, 56)); + // Every tile lands inside the page. + for id in 0..=255u8 { + let (u, v) = tile_uv(id); + assert!(u + 8 <= spec::PAGE_PX as u16); + assert!(v + 8 <= spec::PAGE_PX as u16); + } + } + + #[test] + fn actor_poses_are_twelve_per_row_and_stay_in_bounds() { + assert_eq!(actor_uv(0, spec::dir::DOWN, 0), (0, 0)); + assert_eq!(actor_uv(0, spec::dir::DOWN, 2), (32, 0)); + assert_eq!(actor_uv(0, spec::dir::UP, 0), (48, 0)); + assert_eq!(actor_uv(1, spec::dir::DOWN, 0), (0, 16)); + // An out-of-range frame or direction clamps rather than wandering off + // the sheet into another actor's row. + assert_eq!(actor_uv(0, 200, 200), actor_uv(0, 3, 2)); + for sprite in 0..=255u8 { + for dir in 0..4u8 { + for frame in 0..3u8 { + let (u, v) = actor_uv(sprite, dir, frame); + assert!(u + ACTOR_PX <= spec::PAGE_PX as u16, "u {u}"); + assert!(v + ACTOR_PX <= spec::PAGE_PX as u16, "v {v}"); + } + } + } + } + + #[test] + fn portraits_tile_four_by_four_then_spill_to_the_next_page() { + assert_eq!(portrait_uv(0), (PORTRAIT_PAGE, 0, 0)); + assert_eq!(portrait_uv(1), (PORTRAIT_PAGE, 64, 0)); + assert_eq!(portrait_uv(4), (PORTRAIT_PAGE, 0, 64)); + assert_eq!(portrait_uv(15), (PORTRAIT_PAGE, 192, 192)); + assert_eq!(portrait_uv(16), (PORTRAIT_PAGE + 1, 0, 0)); + // The page index never runs past the atlas. + for i in 0..=255u8 { + let (page, u, v) = portrait_uv(i); + assert!((page as usize) < spec::PAGE_MAX); + assert!(u + PORTRAIT_PX <= spec::PAGE_PX as u16); + assert!(v + PORTRAIT_PX <= spec::PAGE_PX as u16); + } + } + + #[test] + fn an_empty_game_still_draws_a_backdrop() { + let mut g = Game::new(); + let list = g.render(); + // No content at all: a solid backdrop, never an empty frame that would + // leave the previous frame's garbage on screen. + assert!(!list.is_empty()); + } + + #[test] + fn every_drawn_coordinate_stays_inside_the_view() { + let mut g = Game::new(); + g.render(); + for item in &g.draw.items { + let crate::draw::DrawCmd::Rect(r) = item else { continue }; + assert!(r.x >= 0 && r.y >= 0); + assert!(r.x as i32 + r.w as i32 <= spec::VIEW_W); + assert!(r.y as i32 + r.h as i32 <= spec::VIEW_H); + } + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/script.rs b/engine/pocketmon/crates/pocketmon-core/src/script.rs new file mode 100644 index 00000000..c193c801 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/script.rs @@ -0,0 +1,995 @@ +//! The map-script virtual machine. +//! +//! Ported from upstream `src/script/ScriptRunner.lua` + `Commands.lua`. The +//! upstream runner is a Lua coroutine, so `show_text` and `wait` simply block. +//! A coroutine is exactly what this core cannot have (Law 3: one guest turn +//! per host tick, and every frame must be replayable), so the port is a +//! **resumable state machine**: [`ScriptVm::step`] runs instructions until one +//! of them parks the VM on a [`Wait`], and the next frame resumes from the +//! same program counter. +//! +//! The bytecode is what `apps/mon/cook.ts` emits from the TS script tables: +//! +//! ```text +//! header: u16 version | u16 opCount | u16 labelCount | u16 reserved +//! u32 labelOffset[labelCount] +//! stream: u8 verb | u8 argCount | i32 args[argCount] +//! ``` +//! +//! Unknown verbs are not an error — they raise a `scriptHook` event so the +//! guest can implement them in JS. That escape hatch is what lets the native +//! verb list stay closed and small. + +use alloc::string::String; +use alloc::vec::Vec; + +use crate::content::Content; +use crate::event::{EventQueue, MonEvent}; +use crate::rng::Rng; +use crate::spec; +use crate::text::TextBox; +use crate::world::{actor, World}; +use crate::PlayerState; + +/// Instructions executed per frame before the VM yields regardless. +/// +/// A content bug (a `jump` that loops with no blocking verb inside) must cost +/// one frame of animation, never a hung console. +pub const STEP_BUDGET: u32 = 512; + +/// What the VM is parked on. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Wait { + #[default] + None, + /// A textbox is up; resume when its handle completes. + Text(i32), + /// A choice is up; resume with the chosen index. + Choice(i32), + /// Count down N frames. + Frames(u32), + /// An actor is finishing a scripted walk. + Actor(usize), + /// A flag must become set. + Flag(u16), + /// A battle is running. + Battle, +} + +/// A battle the script asked for; the caller starts it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BattleRequest { + Wild { species: u16, level: u8 }, + Trainer { id: u16 }, +} + +/// Everything a running script may touch, borrowed for one step. +pub struct ScriptCtx<'a> { + pub content: &'a Content, + pub world: &'a mut World, + pub player: &'a mut PlayerState, + pub text: &'a mut TextBox, + pub events: &'a mut EventQueue, + pub rng: &'a mut Rng, + /// Set when the script wants a battle started. + pub battle: Option, + /// Set when the script changes the music (-1 = stop). + pub music: Option, + /// Set when the script fires a sound effect. + pub sfx: Option, + /// Set when the script fires a creature cry. + pub cry: Option, +} + +/// The VM. +#[derive(Clone, Debug, Default)] +pub struct ScriptVm { + program: Vec, + labels: Vec, + pc: usize, + running: bool, + wait: Wait, + /// The comparison register the `check*` verbs write and `jumpIf*` reads. + cond: bool, + /// The most recent choice index. + pub last_choice: i32, + /// The outcome of the most recent battle (-1 when none). + pub last_battle: i32, + /// A handle the guest can match `scriptDone` against. + handle: i32, + next_handle: i32, +} + +impl ScriptVm { + pub fn new() -> Self { + ScriptVm { next_handle: 1, last_battle: -1, ..Default::default() } + } + + pub fn running(&self) -> bool { + self.running + } + + pub fn waiting(&self) -> Wait { + self.wait + } + + pub fn handle(&self) -> i32 { + self.handle + } + + /// Load and start a compiled script. Returns its handle, or 0 if the blob + /// does not parse (a bad script must not take the game down). + pub fn start(&mut self, program: &[u8]) -> i32 { + if program.len() < spec::SCRIPT_HEADER_SIZE { + return 0; + } + let version = u16::from_le_bytes([program[0], program[1]]); + if version != spec::SCRIPT_VERSION { + return 0; + } + let label_count = u16::from_le_bytes([program[4], program[5]]) as usize; + let table_end = spec::SCRIPT_HEADER_SIZE + label_count * 4; + if table_end > program.len() { + return 0; + } + self.labels = (0..label_count) + .map(|i| { + let o = spec::SCRIPT_HEADER_SIZE + i * 4; + u32::from_le_bytes([program[o], program[o + 1], program[o + 2], program[o + 3]]) + }) + .collect(); + self.program = program.to_vec(); + self.pc = table_end; + self.running = true; + self.wait = Wait::None; + self.cond = false; + self.handle = self.next_handle; + self.next_handle = self.next_handle.wrapping_add(1).max(1); + self.handle + } + + /// Abandon the running script without emitting `scriptDone`. + pub fn stop(&mut self) { + self.running = false; + self.wait = Wait::None; + self.program.clear(); + self.labels.clear(); + } + + /// Tell the VM a textbox finished. + pub fn on_text_done(&mut self, handle: i32) { + if self.wait == Wait::Text(handle) { + self.wait = Wait::None; + } + } + + /// Tell the VM a choice resolved. + pub fn on_choice(&mut self, handle: i32, index: i32) { + if self.wait == Wait::Choice(handle) { + self.last_choice = index; + // A yes/no `ask` maps index 0 to "yes", which is what the + // following jump_if_true reads. + self.cond = index == 0; + self.wait = Wait::None; + } + } + + /// Tell the VM a battle ended. + pub fn on_battle_end(&mut self, outcome: u8) { + if self.wait == Wait::Battle { + self.last_battle = outcome as i32; + self.wait = Wait::None; + } + } + + /// Read one instruction's header at `pc`: (verb, argc, next pc). + fn read_instr(&self, pc: usize) -> Option<(u8, usize, usize)> { + let verb = *self.program.get(pc)?; + let argc = *self.program.get(pc + 1)? as usize; + let end = pc.checked_add(2)?.checked_add(argc.checked_mul(4)?)?; + if end > self.program.len() { + return None; + } + Some((verb, argc, end)) + } + + /// Read argument `i` of the instruction at `pc`. + /// + /// Bounded by the instruction's own `argCount`, not just by the end of the + /// program: without that, a verb reading an optional argument it was not + /// given would silently pick up the NEXT instruction's opcode bytes as a + /// number. Missing arguments read as 0, which is what every optional + /// argument's default is. + fn arg(&self, pc: usize, i: usize) -> i32 { + let argc = self.program.get(pc + 1).copied().unwrap_or(0) as usize; + if i >= argc { + return 0; + } + let o = pc + 2 + i * 4; + match self.program.get(o..o + 4) { + Some(b) => i32::from_le_bytes([b[0], b[1], b[2], b[3]]), + None => 0, + } + } + + /// Jump to a label index, or halt if it does not exist. + fn jump_to(&mut self, label: i32) { + match self.labels.get(label.max(0) as usize) { + Some(&off) if (off as usize) < self.program.len() => self.pc = off as usize, + _ => self.running = false, + } + } + + /// Run until the VM blocks, finishes, or burns its budget. + pub fn step(&mut self, ctx: &mut ScriptCtx) { + if !self.running { + return; + } + // Resolve time-based waits first so a `wait` of 0 costs nothing extra. + if let Wait::Frames(n) = self.wait { + self.wait = if n <= 1 { Wait::None } else { Wait::Frames(n - 1) }; + if self.wait != Wait::None { + return; + } + } + if let Wait::Actor(slot) = self.wait { + let done = ctx.world.actors.get(slot).map(|a| a.script_done()).unwrap_or(true); + if !done { + return; + } + self.wait = Wait::None; + } + if let Wait::Flag(id) = self.wait { + if !ctx.player.flag(id) { + return; + } + self.wait = Wait::None; + } + if self.wait != Wait::None { + return; + } + + let mut budget = STEP_BUDGET; + while self.running && self.wait == Wait::None { + if budget == 0 { + // Out of budget: yield the frame and pick up here next tick. + return; + } + budget -= 1; + + let Some((verb, _argc, end)) = self.read_instr(self.pc) else { + self.running = false; + break; + }; + let pc = self.pc; + self.pc = end; + self.exec(verb, pc, ctx); + } + + if !self.running { + ctx.events.push(MonEvent { + kind: spec::event::SCRIPT_DONE, + a: 0, + b: self.handle, + c: 0, + d: 0, + }); + self.program.clear(); + self.labels.clear(); + } + } + + fn exec(&mut self, verb: u8, pc: usize, ctx: &mut ScriptCtx) { + use spec::verb as v; + match verb { + v::END => self.running = false, + v::LABEL => {} + + v::SHOW_TEXT => { + let key = self.arg(pc, 0) as u16; + let s = String::from(ctx.content.string(key)); + let h = ctx.text.show(ctx.content, &s); + // An empty line opens no box, so parking on it would deadlock. + if h != 0 && ctx.text.active() { + self.wait = Wait::Text(h); + } + } + v::ASK | v::CHOICE => { + let key = self.arg(pc, 0) as u16; + let s = String::from(ctx.content.string(key)); + let yes_key = self.arg(pc, 1) as u16; + let no_key = self.arg(pc, 2) as u16; + let yes = + String::from(if yes_key == 0 { "YES" } else { ctx.content.string(yes_key) }); + let no = String::from(if no_key == 0 { "NO" } else { ctx.content.string(no_key) }); + let h = ctx.text.show_choice(ctx.content, &s, &[&yes, &no]); + if h != 0 && ctx.text.active() { + self.wait = Wait::Choice(h); + } + } + + v::JUMP => self.jump_to(self.arg(pc, 0)), + v::JUMP_IF_TRUE => { + if self.cond { + self.jump_to(self.arg(pc, 0)); + } + } + v::JUMP_IF_FALSE => { + if !self.cond { + self.jump_to(self.arg(pc, 0)); + } + } + + v::SET_FLAG => ctx.player.set_flag(self.arg(pc, 0) as u16, true), + v::CLEAR_FLAG => ctx.player.set_flag(self.arg(pc, 0) as u16, false), + v::CHECK_FLAG => self.cond = ctx.player.flag(self.arg(pc, 0) as u16), + v::WAIT_FLAG => { + let id = self.arg(pc, 0) as u16; + if !ctx.player.flag(id) { + self.wait = Wait::Flag(id); + } + } + + v::CHECK_ITEM => { + let item = self.arg(pc, 0) as u16; + let qty = self.arg(pc, 1).max(1) as u8; + self.cond = ctx.player.bag.count(item) >= qty; + } + v::GIVE_ITEM => { + let item = self.arg(pc, 0) as u16; + let qty = self.arg(pc, 1).max(1) as u8; + self.cond = ctx.player.bag.add(item, qty); + } + v::TAKE_ITEM => { + let item = self.arg(pc, 0) as u16; + let qty = self.arg(pc, 1).max(1) as u8; + self.cond = ctx.player.bag.take(item, qty); + } + v::GIVE_MONEY => { + let amount = self.arg(pc, 0); + if amount >= 0 { + ctx.player.money = ctx.player.money.saturating_add(amount as u32); + } else { + ctx.player.money = ctx.player.money.saturating_sub((-amount) as u32); + } + } + + v::GIVEMON => { + let species = self.arg(pc, 0) as u16; + let level = self.arg(pc, 1).clamp(1, spec::LEVEL_MAX as i32) as u8; + match crate::mon::MonInstance::wild(ctx.content, species, level, ctx.rng) { + Some(m) => { + ctx.player.own(species); + self.cond = ctx.player.party.add(m).is_some(); + } + None => self.cond = false, + } + } + v::HEAL_PARTY => ctx.player.party.heal_all(), + + v::START_BATTLE => { + let species = self.arg(pc, 0) as u16; + let level = self.arg(pc, 1).clamp(1, spec::LEVEL_MAX as i32) as u8; + ctx.battle = Some(BattleRequest::Wild { species, level }); + self.wait = Wait::Battle; + } + v::TRAINER_BATTLE => { + let id = self.arg(pc, 0).max(0) as u16; + ctx.battle = Some(BattleRequest::Trainer { id }); + self.wait = Wait::Battle; + } + v::CHECK_BATTLE_RESULT => { + // True when the player won (or caught), which is what every + // "you beat me!" branch wants. + self.cond = self.last_battle == spec::outcome::WIN as i32 + || self.last_battle == spec::outcome::CAUGHT as i32; + } + + v::WARP => { + let map = self.arg(pc, 0) as u16; + let cx = self.arg(pc, 1); + let cy = self.arg(pc, 2); + let dir = self.arg(pc, 3).clamp(0, 3) as u8; + ctx.world.warp_to(map, cx, cy, dir, true); + } + v::REPLACE_BLOCK => { + let bx = self.arg(pc, 0); + let by = self.arg(pc, 1); + let block = self.arg(pc, 2).clamp(0, 255) as u8; + ctx.world.pending_block = Some((bx, by, block)); + } + + v::WAIT | v::FADE => { + let frames = self.arg(pc, 0).max(0) as u32; + if frames > 0 { + self.wait = Wait::Frames(frames); + } + } + + v::MOVE_PLAYER => { + let dir = self.arg(pc, 0).clamp(0, 3) as u8; + let count = self.arg(pc, 1).clamp(0, 255) as u8; + ctx.world.actors[0].queue_move(dir, count); + self.wait = Wait::Actor(0); + } + v::MOVE_NPC => { + let slot = self.arg(pc, 0).max(0) as usize; + let dir = self.arg(pc, 1).clamp(0, 3) as u8; + let count = self.arg(pc, 2).clamp(0, 255) as u8; + if let Some(a) = ctx.world.actors.get_mut(slot) { + a.queue_move(dir, count); + self.wait = Wait::Actor(slot); + } + } + v::FACE_NPC => { + let slot = self.arg(pc, 0).max(0) as usize; + let dir = self.arg(pc, 1).clamp(0, 3) as u8; + if let Some(a) = ctx.world.actors.get_mut(slot) { + a.dir = dir; + } + } + v::FACE_PLAYER => { + let slot = self.arg(pc, 0).max(0) as usize; + let (px, py) = (ctx.world.actors[0].cx, ctx.world.actors[0].cy); + if let Some(a) = ctx.world.actors.get_mut(slot) { + a.face_toward(px, py); + } + // ...and the player looks back. + let dir = ctx + .world + .actors + .get(slot) + .map(|a| actor::opposite(a.dir)) + .unwrap_or(ctx.world.actors[0].dir); + ctx.world.actors[0].dir = dir; + } + v::SHOW_OBJECT => { + let slot = self.arg(pc, 0).max(0) as usize; + if let Some(a) = ctx.world.actors.get_mut(slot) { + a.visible = true; + } + } + v::HIDE_OBJECT => { + let slot = self.arg(pc, 0).max(0) as usize; + if let Some(a) = ctx.world.actors.get_mut(slot) { + a.visible = false; + } + } + + v::PLAY_MUSIC => ctx.music = Some(self.arg(pc, 0)), + v::STOP_MUSIC => ctx.music = Some(-1), + v::PLAY_SOUND => ctx.sfx = Some(self.arg(pc, 0)), + v::PLAY_CRY => ctx.cry = Some(self.arg(pc, 0).max(0) as u16), + + // Everything the core does not implement natively goes to the + // guest — the surface's escape hatch (docs/MON.md §3). + _ => self.hook(verb, pc, ctx), + } + } + + fn hook(&mut self, verb: u8, pc: usize, ctx: &mut ScriptCtx) { + ctx.events.push(MonEvent { + kind: spec::event::SCRIPT_HOOK, + a: verb as u16, + b: self.arg(pc, 0), + c: self.arg(pc, 1), + d: self.arg(pc, 2), + }); + } +} + +/// Assemble a script from `(verb, args)` rows — the encoder `apps/mon/cook.ts` +/// mirrors, and the one the tests use directly. +/// +/// `labels[i]` is the ROW index that label `i` points at. +pub fn assemble(rows: &[(u8, &[i32])], labels: &[usize]) -> Vec { + // First pass: the byte offset of every row. + let mut offsets = Vec::with_capacity(rows.len() + 1); + let table = spec::SCRIPT_HEADER_SIZE + labels.len() * 4; + let mut at = table; + for (_, args) in rows { + offsets.push(at); + at += 2 + args.len() * 4; + } + offsets.push(at); + + let mut out = Vec::with_capacity(at); + out.extend_from_slice(&spec::SCRIPT_VERSION.to_le_bytes()); + out.extend_from_slice(&(rows.len() as u16).to_le_bytes()); + out.extend_from_slice(&(labels.len() as u16).to_le_bytes()); + out.extend_from_slice(&0u16.to_le_bytes()); + for &row in labels { + let off = offsets.get(row).copied().unwrap_or(at) as u32; + out.extend_from_slice(&off.to_le_bytes()); + } + for (verb, args) in rows { + out.push(*verb); + out.push(args.len() as u8); + for a in args.iter() { + out.extend_from_slice(&a.to_le_bytes()); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::{Glyph, MapDef, Species, Tileset}; + use crate::world::WorldGate; + use alloc::vec; + + fn content() -> Content { + let mut c = Content::new(); + c.strings = vec![ + String::new(), + String::from("HELLO"), + String::from("BYE"), + String::from("WELL?"), + ]; + for cp in 32u32..127 { + c.glyphs.push(Glyph { codepoint: cp, u: 0, v: 0, w: 8, h: 8, advance: 8 }); + } + c.glyphs.sort_unstable_by_key(|g| g.codepoint); + c.species.insert( + 1, + Species { + id: 1, + base_hp: 40, + base_atk: 40, + base_def: 40, + base_spd: 40, + base_spc: 40, + ..Default::default() + }, + ); + c + } + + struct Harness { + content: Content, + world: World, + player: PlayerState, + text: TextBox, + events: EventQueue, + rng: Rng, + } + + impl Harness { + fn new() -> Self { + Harness { + content: content(), + world: World::new(), + player: PlayerState::new(), + text: TextBox::new(), + events: EventQueue::new(), + rng: Rng::new(1), + } + } + + fn ctx(&mut self) -> ScriptCtx<'_> { + ScriptCtx { + content: &self.content, + world: &mut self.world, + player: &mut self.player, + text: &mut self.text, + events: &mut self.events, + rng: &mut self.rng, + battle: None, + music: None, + sfx: None, + cry: None, + } + } + + /// One `step`, returning any battle the script asked for. + fn step(&mut self, vm: &mut ScriptVm) -> Option { + let mut ctx = self.ctx(); + vm.step(&mut ctx); + ctx.battle + } + } + + /// Run the VM for N frames, dismissing any textbox the way a player would. + fn run(vm: &mut ScriptVm, h: &mut Harness, frames: usize) { + for _ in 0..frames { + h.step(vm); + if h.text.active() { + h.text.tick(spec::btn::A, &mut h.events); + h.text.tick(spec::btn::A, &mut h.events); + } + for e in h.events.drain() { + if e.kind == spec::event::TEXT_DONE { + vm.on_text_done(e.b); + } + if e.kind == spec::event::CHOICE_DONE { + vm.on_choice(e.b, e.c); + } + } + } + } + + #[test] + fn an_empty_script_finishes_immediately() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble(&[(spec::verb::END, &[])], &[]); + assert!(vm.start(&prog) > 0); + h.step(&mut vm); + assert!(!vm.running()); + assert!(h.events.find(spec::event::SCRIPT_DONE).is_some()); + } + + #[test] + fn a_malformed_program_is_refused_not_run() { + let mut vm = ScriptVm::new(); + assert_eq!(vm.start(&[]), 0); + assert_eq!(vm.start(&[0, 0, 0, 0, 0, 0, 0, 0]), 0, "wrong version"); + // A label table that runs past the end of the blob. + let mut prog = Vec::new(); + prog.extend_from_slice(&spec::SCRIPT_VERSION.to_le_bytes()); + prog.extend_from_slice(&1u16.to_le_bytes()); + prog.extend_from_slice(&99u16.to_le_bytes()); + prog.extend_from_slice(&0u16.to_le_bytes()); + assert_eq!(vm.start(&prog), 0); + assert!(!vm.running()); + } + + #[test] + fn a_truncated_instruction_stops_the_script() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let mut prog = assemble(&[(spec::verb::SET_FLAG, &[1])], &[]); + prog.truncate(prog.len() - 2); // chop the argument in half + assert!(vm.start(&prog) > 0); + h.step(&mut vm); + assert!(!vm.running(), "a short read halts rather than reading garbage"); + } + + #[test] + fn flags_round_trip_and_drive_branches() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble( + &[ + (spec::verb::SET_FLAG, &[5]), + (spec::verb::CHECK_FLAG, &[5]), + (spec::verb::JUMP_IF_TRUE, &[0]), + (spec::verb::END, &[]), + (spec::verb::LABEL, &[]), + (spec::verb::SET_FLAG, &[9]), + (spec::verb::END, &[]), + ], + &[4], + ); + vm.start(&prog); + h.step(&mut vm); + assert!(h.player.flag(5)); + assert!(h.player.flag(9), "the true branch ran"); + } + + #[test] + fn a_false_check_takes_the_other_branch() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble( + &[ + (spec::verb::CHECK_FLAG, &[5]), + (spec::verb::JUMP_IF_FALSE, &[0]), + (spec::verb::SET_FLAG, &[1]), + (spec::verb::END, &[]), + (spec::verb::LABEL, &[]), + (spec::verb::SET_FLAG, &[2]), + (spec::verb::END, &[]), + ], + &[4], + ); + vm.start(&prog); + h.step(&mut vm); + assert!(!h.player.flag(1)); + assert!(h.player.flag(2)); + } + + #[test] + fn a_jump_to_a_missing_label_halts_instead_of_running_wild() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble(&[(spec::verb::JUMP, &[7])], &[]); + vm.start(&prog); + h.step(&mut vm); + assert!(!vm.running()); + } + + #[test] + fn an_infinite_loop_yields_the_frame_rather_than_hanging() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + // label 0: jump 0 — a content bug with no blocking verb inside. + let prog = assemble(&[(spec::verb::LABEL, &[]), (spec::verb::JUMP, &[0])], &[0]); + vm.start(&prog); + h.step(&mut vm); // must return + assert!(vm.running(), "still going, but it gave the frame back"); + } + + #[test] + fn show_text_parks_the_vm_until_the_box_closes() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble( + &[ + (spec::verb::SHOW_TEXT, &[1]), + (spec::verb::SET_FLAG, &[3]), + (spec::verb::END, &[]), + ], + &[], + ); + vm.start(&prog); + h.step(&mut vm); + assert!(matches!(vm.waiting(), Wait::Text(_))); + assert!(h.text.active()); + assert!(!h.player.flag(3), "the script is parked"); + + run(&mut vm, &mut h, 10); + assert!(h.player.flag(3), "it resumed after the box closed"); + assert!(!vm.running()); + } + + #[test] + fn an_empty_line_does_not_deadlock_the_script() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + // Key 0 is the empty string: no box opens, so the VM must not park. + let prog = assemble( + &[ + (spec::verb::SHOW_TEXT, &[0]), + (spec::verb::SET_FLAG, &[4]), + (spec::verb::END, &[]), + ], + &[], + ); + vm.start(&prog); + h.step(&mut vm); + assert!(h.player.flag(4)); + assert!(!vm.running()); + } + + #[test] + fn ask_branches_on_the_players_answer() { + for (answer, expect_yes) in [(0i32, true), (1, false)] { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble( + &[ + (spec::verb::ASK, &[3, 0, 0]), + (spec::verb::JUMP_IF_TRUE, &[0]), + (spec::verb::SET_FLAG, &[2]), // no + (spec::verb::END, &[]), + (spec::verb::LABEL, &[]), + (spec::verb::SET_FLAG, &[1]), // yes + (spec::verb::END, &[]), + ], + &[4], + ); + vm.start(&prog); + h.step(&mut vm); + assert!(matches!(vm.waiting(), Wait::Choice(_))); + let handle = h.text.handle(); + vm.on_choice(handle, answer); + h.step(&mut vm); + assert_eq!(h.player.flag(1), expect_yes); + assert_eq!(h.player.flag(2), !expect_yes); + } + } + + #[test] + fn wait_counts_down_frames() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble( + &[(spec::verb::WAIT, &[3]), (spec::verb::SET_FLAG, &[7]), (spec::verb::END, &[])], + &[], + ); + vm.start(&prog); + for _ in 0..3 { + h.step(&mut vm); + assert!(!h.player.flag(7)); + } + h.step(&mut vm); + assert!(h.player.flag(7)); + } + + #[test] + fn items_and_money_move_through_the_bag() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble( + &[ + (spec::verb::GIVE_ITEM, &[4, 3]), + (spec::verb::CHECK_ITEM, &[4, 2]), + (spec::verb::JUMP_IF_FALSE, &[0]), + (spec::verb::TAKE_ITEM, &[4, 1]), + (spec::verb::GIVE_MONEY, &[500]), + (spec::verb::LABEL, &[]), + (spec::verb::END, &[]), + ], + &[5], + ); + vm.start(&prog); + h.step(&mut vm); + assert_eq!(h.player.bag.count(4), 2); + assert_eq!(h.player.money, 500); + } + + #[test] + fn giving_money_can_also_take_it_without_underflowing() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble(&[(spec::verb::GIVE_MONEY, &[-100]), (spec::verb::END, &[])], &[]); + vm.start(&prog); + h.step(&mut vm); + assert_eq!(h.player.money, 0); + } + + #[test] + fn givemon_adds_to_the_party_and_the_dex() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble(&[(spec::verb::GIVEMON, &[1, 5]), (spec::verb::END, &[])], &[]); + vm.start(&prog); + h.step(&mut vm); + assert_eq!(h.player.party.len(), 1); + assert_eq!(h.player.party.get(0).unwrap().level, 5); + assert!(h.player.owned(1)); + } + + #[test] + fn a_battle_verb_parks_until_the_result_arrives() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble( + &[ + (spec::verb::START_BATTLE, &[1, 7]), + (spec::verb::CHECK_BATTLE_RESULT, &[]), + (spec::verb::JUMP_IF_TRUE, &[0]), + (spec::verb::SET_FLAG, &[2]), // lost + (spec::verb::END, &[]), + (spec::verb::LABEL, &[]), + (spec::verb::SET_FLAG, &[1]), // won + (spec::verb::END, &[]), + ], + &[5], + ); + vm.start(&prog); + let request = h.step(&mut vm); + assert_eq!(request, Some(BattleRequest::Wild { species: 1, level: 7 })); + assert_eq!(vm.waiting(), Wait::Battle); + + vm.on_battle_end(spec::outcome::WIN); + h.step(&mut vm); + assert!(h.player.flag(1), "the win branch ran"); + assert!(!h.player.flag(2)); + } + + #[test] + fn a_lost_battle_takes_the_other_branch() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble( + &[ + (spec::verb::TRAINER_BATTLE, &[3]), + (spec::verb::CHECK_BATTLE_RESULT, &[]), + (spec::verb::JUMP_IF_TRUE, &[0]), + (spec::verb::SET_FLAG, &[2]), + (spec::verb::END, &[]), + (spec::verb::LABEL, &[]), + (spec::verb::SET_FLAG, &[1]), + (spec::verb::END, &[]), + ], + &[5], + ); + vm.start(&prog); + let request = h.step(&mut vm); + assert_eq!(request, Some(BattleRequest::Trainer { id: 3 })); + vm.on_battle_end(spec::outcome::LOSS); + h.step(&mut vm); + assert!(h.player.flag(2)); + } + + #[test] + fn an_unknown_verb_becomes_a_guest_hook() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble(&[(200u8, &[11, 22, 33]), (spec::verb::END, &[])], &[]); + vm.start(&prog); + h.step(&mut vm); + let hook = h.events.find(spec::event::SCRIPT_HOOK).copied().expect("hook"); + assert_eq!(hook.a, 200); + assert_eq!((hook.b, hook.c, hook.d), (11, 22, 33)); + assert!(!vm.running(), "the script carried on past the hook"); + } + + #[test] + fn music_and_sound_requests_reach_the_host() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble( + &[ + (spec::verb::PLAY_MUSIC, &[4]), + (spec::verb::PLAY_SOUND, &[9]), + (spec::verb::PLAY_CRY, &[1]), + (spec::verb::END, &[]), + ], + &[], + ); + vm.start(&prog); + let mut ctx = h.ctx(); + vm.step(&mut ctx); + assert_eq!(ctx.music, Some(4)); + assert_eq!(ctx.sfx, Some(9)); + assert_eq!(ctx.cry, Some(1)); + } + + #[test] + fn stopping_a_script_emits_nothing() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + let prog = assemble(&[(spec::verb::WAIT, &[100]), (spec::verb::END, &[])], &[]); + vm.start(&prog); + h.step(&mut vm); + vm.stop(); + assert!(!vm.running()); + assert!(h.events.find(spec::event::SCRIPT_DONE).is_none()); + } + + #[test] + fn a_scripted_walk_parks_until_the_actor_arrives() { + let mut h = Harness::new(); + let mut vm = ScriptVm::new(); + // Give the world a floor to walk on. + let mut c = content(); + let mut behavior = [spec::cell::WALL; spec::TILE_BEHAVIOR_BYTES]; + behavior[1] = spec::cell::FLOOR; + c.tilesets.push(Tileset { blocks: vec![[1u8; 16]], behavior }); + c.maps.insert( + 1, + MapDef { + id: 1, + width: 4, + height: 4, + tileset: 0, + blocks: vec![0; 16], + conn: [-1; 4], + ..Default::default() + }, + ); + h.content = c; + let flags = h.player.flags.clone(); + h.world.enter_map(&h.content, &flags, 1, 2, 2, spec::dir::DOWN); + + let prog = assemble( + &[ + (spec::verb::MOVE_PLAYER, &[spec::dir::RIGHT as i32, 2]), + (spec::verb::SET_FLAG, &[6]), + (spec::verb::END, &[]), + ], + &[], + ); + vm.start(&prog); + h.step(&mut vm); + assert_eq!(vm.waiting(), Wait::Actor(0)); + + // Drive the world until the queued walk drains. + let mut rng = Rng::new(1); + for _ in 0..200 { + let mut events = EventQueue::new(); + h.world + .update(&h.content, &flags, &mut rng, 0, 0, WorldGate::Held, &mut events); + h.step(&mut vm); + if !vm.running() { + break; + } + } + assert!(h.player.flag(6), "the script resumed after the walk"); + assert_eq!(h.world.player().cx, 4, "walked two cells right"); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/spec.rs b/engine/pocketmon/crates/pocketmon-core/src/spec.rs new file mode 100644 index 00000000..ab0a6263 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/spec.rs @@ -0,0 +1,518 @@ +//! GENERATED — do not edit; run `bun contracts/spec/gen-mon-rust.ts` (from PocketJS/). +//! +//! Source of truth: contracts/spec/mon-spec.ts — every constant here mirrors it. +//! tests/mon-contract.test.ts regenerates this file in-memory and byte-compares; +//! if that fails, run `bun contracts/spec/gen-mon-rust.ts` and commit the result. +//! +//! See docs/MON.md for the architecture this contract serves. + +#![allow(dead_code)] +#![allow(clippy::all)] + +// --------------------------------------------------------------------------- +// Geometry — the three coordinate units (docs/MON.md §4) +// --------------------------------------------------------------------------- + +/// Graphics unit: an 8x8 pixel tile. +pub const TILE_PX: i32 = 8; +/// Walk-grid unit: a 16x16 pixel cell = 2x2 tiles. +pub const CELL_PX: i32 = 16; +/// Layout unit: a 32x32 pixel block = 2x2 cells = 4x4 tiles. +pub const BLOCK_PX: i32 = 32; +/// Tiles per block edge; a block's tile array is BLOCK_TILES^2 entries. +pub const BLOCK_TILES: usize = 4; +/// Cells per block edge. +pub const BLOCK_CELLS: i32 = 2; +/// The logical view, in GB-scale pixels (the PSP host renders it 2x). +pub const VIEW_W: i32 = 240; +pub const VIEW_H: i32 = 136; +/// The original handheld framing, for reference and 1x hosts. +pub const GB_W: i32 = 160; +pub const GB_H: i32 = 144; +/// Fixed simulation step. +pub const TICK_HZ: u32 = 60; + +/// The core's abstract button set; hosts map their physical buttons onto +/// it, so one input tape replays on every host. +pub mod btn { + pub const UP: u32 = 1; + pub const DOWN: u32 = 2; + pub const LEFT: u32 = 4; + pub const RIGHT: u32 = 8; + pub const A: u32 = 16; + pub const B: u32 = 32; + pub const START: u32 = 64; + pub const SELECT: u32 = 128; +} + +// --------------------------------------------------------------------------- +// Party / bag / box limits +// --------------------------------------------------------------------------- + +pub const PARTY_MAX: usize = 6; +pub const MOVES_MAX: usize = 4; +pub const BOX_COUNT: usize = 8; +pub const BOX_SIZE: usize = 20; +pub const BAG_MAX: usize = 20; +/// Max simultaneously-loaded actors on a map (player is slot 0). +pub const ACTORS_MAX: usize = 16; +pub const LEVEL_MAX: u32 = 100; +pub const STAGE_MIN: i32 = -6; +pub const STAGE_MAX: i32 = 6; + +// --------------------------------------------------------------------------- +// Enums — closed vocabularies the core and guest share +// --------------------------------------------------------------------------- + +/// Facing / movement direction; matches walk-sheet frame order. +pub mod dir { + pub const DOWN: u8 = 0; + pub const UP: u8 = 1; + pub const LEFT: u8 = 2; + pub const RIGHT: u8 = 3; +} + +/// What a cell does, derived from its bottom-left 8x8 tile. +pub mod cell { + pub const WALL: u8 = 0; + pub const FLOOR: u8 = 1; + pub const GRASS: u8 = 2; + pub const WATER: u8 = 3; + pub const DOOR: u8 = 4; + pub const WARP: u8 = 5; + pub const LEDGE_DOWN: u8 = 6; + pub const COUNTER: u8 = 7; +} + +/// Persistent status conditions; `NONE` stays 0. +pub mod status { + pub const NONE: u8 = 0; + pub const SLEEP: u8 = 1; + pub const POISON: u8 = 2; + pub const BURN: u8 = 3; + pub const FREEZE: u8 = 4; + pub const PARALYSIS: u8 = 5; + pub const BAD_POISON: u8 = 6; +} + +/// Damage category. Gen 1 splits physical/special by the move's TYPE; +/// a type record carries its category and a move may override it. +pub mod category { + pub const PHYSICAL: u8 = 0; + pub const SPECIAL: u8 = 1; + pub const STATUS: u8 = 2; +} + +/// Experience growth curves (see growth.rs). +pub mod growth { + pub const MEDIUM_FAST: u8 = 0; + pub const SLIGHTLY_FAST: u8 = 1; + pub const SLIGHTLY_SLOW: u8 = 2; + pub const MEDIUM_SLOW: u8 = 3; + pub const FAST: u8 = 4; + pub const SLOW: u8 = 5; +} + +/// The top-level mode the core is in. +pub mod mode { + pub const OVERWORLD: u8 = 0; + pub const TEXT: u8 = 1; + pub const BATTLE: u8 = 2; + pub const MENU: u8 = 3; + pub const TRANSITION: u8 = 4; +} + +/// Battle phase — what the core is waiting for. +pub mod phase { + pub const INTRO: u8 = 0; + pub const CHOOSE_ACTION: u8 = 1; + pub const CHOOSE_MOVE: u8 = 2; + pub const RESOLVING: u8 = 3; + pub const MESSAGE: u8 = 4; + pub const CHOOSE_SWITCH: u8 = 5; + pub const ENDED: u8 = 6; +} + +/// The player's battle action. +pub mod action { + pub const FIGHT: u8 = 0; + pub const BAG: u8 = 1; + pub const SWAP: u8 = 2; + pub const RUN: u8 = 3; +} + +/// How a battle finished. +pub mod outcome { + pub const WIN: u8 = 0; + pub const LOSS: u8 = 1; + pub const RAN: u8 = 2; + pub const CAUGHT: u8 = 3; + pub const DRAW: u8 = 4; +} + +/// Actor movement behavior for NPCs. +pub mod behavior { + pub const STILL: u8 = 0; + pub const WANDER: u8 = 1; + pub const PACE_H: u8 = 2; + pub const PACE_V: u8 = 3; + pub const SPIN: u8 = 4; +} + +// --------------------------------------------------------------------------- +// The `mon` surface: ops (guest -> core) and events (core -> guest) +// --------------------------------------------------------------------------- + +/// Guest -> core intent. APPEND ONLY: never renumber, never reuse. +/// Signatures are documented in contracts/spec/mon-spec.ts. +pub mod op { + pub const LOAD_CONTENT: u32 = 1; + pub const DEFINE_TYPE: u32 = 2; + pub const DEFINE_SPECIES: u32 = 3; + pub const DEFINE_MOVE: u32 = 4; + pub const DEFINE_ITEM: u32 = 5; + pub const DEFINE_MAP: u32 = 6; + pub const DEFINE_SCRIPT: u32 = 7; + pub const DEFINE_TEXT: u32 = 8; + pub const DEFINE_TRAINER: u32 = 9; + pub const ENTER_MAP: u32 = 20; + pub const WARP_TO: u32 = 21; + pub const SET_ACTOR: u32 = 22; + pub const HIDE_ACTOR: u32 = 23; + pub const SHOW_ACTOR: u32 = 24; + pub const MOVE_ACTOR: u32 = 25; + pub const FACE_ACTOR: u32 = 26; + pub const SET_FLAG: u32 = 27; + pub const GET_FLAG: u32 = 28; + pub const SET_BLOCK: u32 = 29; + pub const SHOW_TEXT: u32 = 30; + pub const SHOW_CHOICE: u32 = 31; + pub const CLOSE_TEXT: u32 = 32; + pub const SET_MODE: u32 = 33; + pub const PLAY_MUSIC: u32 = 34; + pub const STOP_MUSIC: u32 = 35; + pub const PLAY_SFX: u32 = 36; + pub const PLAY_CRY: u32 = 37; + pub const GIVEMON: u32 = 50; + pub const HEAL_PARTY: u32 = 51; + pub const GIVE_ITEM: u32 = 52; + pub const TAKE_ITEM: u32 = 53; + pub const SET_PARTY_MOVE: u32 = 54; + pub const START_WILD: u32 = 70; + pub const START_TRAINER: u32 = 71; + pub const CHOOSE_ACTION: u32 = 72; + pub const CHOOSE_MOVE: u32 = 73; + pub const CHOOSE_ITEM: u32 = 74; + pub const CHOOSE_SWITCH: u32 = 75; + pub const ADVANCE: u32 = 76; + pub const END_BATTLE: u32 = 77; + pub const VIEW: u32 = 90; + pub const PARTY_SLOT: u32 = 91; + pub const TEXT: u32 = 92; + pub const SAVE: u32 = 110; + pub const LOAD: u32 = 111; + pub const HAS_SAVE: u32 = 112; + pub const SEED: u32 = 113; + pub const VIEWPORT: u32 = 114; + pub const EVENTS: u32 = 115; + pub const FRAME_STATS: u32 = 116; +} + +/// Core -> guest facts, drained as one batch per tick. APPEND ONLY. +pub mod event { + pub const TALK: u16 = 1; + pub const SIGN: u16 = 2; + pub const WARPED: u16 = 3; + pub const ENCOUNTER: u16 = 4; + pub const BATTLE_ENDED: u16 = 5; + pub const SCRIPT_DONE: u16 = 6; + pub const TEXT_DONE: u16 = 7; + pub const CHOICE_DONE: u16 = 8; + pub const MENU_REQUEST: u16 = 9; + pub const LEVEL_UP: u16 = 10; + pub const EVOLVE: u16 = 11; + pub const CAUGHT: u16 = 12; + pub const FAINT: u16 = 13; + pub const SCRIPT_HOOK: u16 = 14; + pub const BATTLE_PROMPT: u16 = 15; +} + +/// Bytes per packed event record: u16 kind | u16 a | i32 b | i32 c | i32 d. +pub const EVENT_SIZE: usize = 16; +/// Max events buffered in one tick; overflow drops the tail and sets a stat. +pub const EVENT_CAP: usize = 64; + +/// `view(kind)` packed snapshots for menu rendering. +pub mod view { + pub const WORLD: u32 = 0; + pub const PARTY: u32 = 1; + pub const BATTLE: u32 = 2; + pub const BAG: u32 = 3; + pub const PLAYER: u32 = 4; + pub const DEX: u32 = 5; +} + +// --------------------------------------------------------------------------- +// MONPAK — the cooked content container +// --------------------------------------------------------------------------- + +pub mod monpak { + pub const MAGIC: u32 = 0x504e4f4d; // 'MONP' LE + pub const VERSION: u16 = 1; + pub const HEADER_SIZE: usize = 16; + pub const ENTRY_SIZE: usize = 16; + pub const ALIGN: usize = 16; + + /// Section tags (4CC, LE u32). + pub const TAG_ATLAS: u32 = 0x534c5441; + pub const TAG_PALETTE: u32 = 0x4c415041; + pub const TAG_TILESET: u32 = 0x53454c54; + pub const TAG_MAPS: u32 = 0x5350414d; + pub const TAG_SPECIES: u32 = 0x43455053; + pub const TAG_MOVES: u32 = 0x564f4d53; + pub const TAG_TYPES: u32 = 0x50595453; + pub const TAG_ITEMS: u32 = 0x4d455449; + pub const TAG_TRAINERS: u32 = 0x4e525254; + pub const TAG_SCRIPTS: u32 = 0x54504353; + pub const TAG_TEXT: u32 = 0x54584554; + pub const TAG_FONT: u32 = 0x544e4f46; + pub const TAG_AUDIO: u32 = 0x4f445541; +} + +// --------------------------------------------------------------------------- +// Section payload layouts +// --------------------------------------------------------------------------- + +/// Bytes of per-section header preceding every payload's records. +pub const SECTION_HEADER_SIZE: usize = 4; +/// ATLS page header: u16 w, u16 h, u32 byteLen, then w*h CLUT8 pixels. +pub const ATLAS_PAGE_HEADER_SIZE: usize = 8; +pub const PALETTE_ENTRIES: usize = 256; +pub const PALETTE_BYTES: usize = 1024; +/// TLES per-tileset header, block record size, and the tile behavior table. +pub const TILESET_HEADER_SIZE: usize = 4; +pub const TILESET_BLOCK_SIZE: usize = 16; +pub const TILE_BEHAVIOR_BYTES: usize = 256; +/// SPEC header: u16 count, u16 learnPoolCount. +pub const SPECIES_SECTION_HEADER_SIZE: usize = 4; +/// One learnset entry: u16 level, u16 moveId. +pub const LEARN_SIZE: usize = 4; +/// STYP header: u16 typeCount, u16 matchupCount. +pub const TYPE_SECTION_HEADER_SIZE: usize = 4; +pub const TYPE_SIZE: usize = 4; +pub const MATCHUP_SIZE: usize = 4; +pub const ITEM_SIZE: usize = 12; +pub const FONT_HEADER_SIZE: usize = 4; +pub const GLYPH_SIZE: usize = 12; +pub const SCRIPT_ENTRY_SIZE: usize = 12; +/// A string's index IS its key id; no hashing happens at runtime. +pub const TEXT_ENTRY_SIZE: usize = 8; +pub const AUDIO_ENTRY_SIZE: usize = 4; + +/// What an item does when used. +pub mod item_kind { + pub const NONE: u8 = 0; + pub const BALL: u8 = 1; + pub const HEAL: u8 = 2; + pub const STATUS: u8 = 3; + pub const REVIVE: u8 = 4; + pub const BOOST: u8 = 5; + pub const KEY: u8 = 6; + pub const ESCAPE: u8 = 7; + pub const REPEL: u8 = 8; +} + +// --------------------------------------------------------------------------- +// Record layouts (fixed-size, LE) — the cooker writes, the core reads +// --------------------------------------------------------------------------- + +/// SPECIES record byte size (layout in mon-spec.ts). +pub const SPECIES_SIZE: usize = 32; +/// MOVE record byte size. +pub const MOVE_SIZE: usize = 16; +/// Move `flags` bits. +pub const MOVE_FLAG_HIGH_CRIT: u8 = 1; +pub const MOVE_FLAG_MULTI_HIT: u8 = 2; +pub const MOVE_FLAG_CHARGE: u8 = 4; +pub const MOVE_FLAG_RECHARGE: u8 = 8; +/// Moves first regardless of speed (the "quick attack" class). +pub const MOVE_FLAG_PRIORITY: u8 = 16; + +/// Move effects the core implements natively; anything else raises +/// a `scriptHook` event for the guest. Append-only. +pub mod effect { + pub const NONE: u8 = 0; + pub const BURN_CHANCE: u8 = 1; + pub const FREEZE_CHANCE: u8 = 2; + pub const PARALYZE_CHANCE: u8 = 3; + pub const POISON_CHANCE: u8 = 4; + pub const SLEEP: u8 = 5; + pub const CONFUSE: u8 = 6; + pub const FLINCH_CHANCE: u8 = 7; + pub const ATK_DOWN: u8 = 8; + pub const DEF_DOWN: u8 = 9; + pub const SPD_DOWN: u8 = 10; + pub const SPC_DOWN: u8 = 11; + pub const ACC_DOWN: u8 = 12; + pub const ATK_UP: u8 = 13; + pub const DEF_UP: u8 = 14; + pub const SPD_UP: u8 = 15; + pub const SPC_UP: u8 = 16; + pub const DRAIN: u8 = 17; + pub const RECOIL: u8 = 18; + pub const MULTI_HIT: u8 = 19; + pub const TWO_HIT: u8 = 20; + pub const OHKO: u8 = 21; + pub const HIGH_CRIT: u8 = 22; + pub const CHARGE: u8 = 23; + pub const HYPER_BEAM: u8 = 24; + pub const REFLECT: u8 = 25; + pub const LIGHT_SCREEN: u8 = 26; + pub const HAZE: u8 = 27; + pub const HEAL: u8 = 28; + pub const REST: u8 = 29; + pub const EXPLODE: u8 = 30; + pub const FIXED_DAMAGE: u8 = 31; + pub const LEVEL_DAMAGE: u8 = 32; + pub const SUPER_FANG: u8 = 33; + pub const SWIFT: u8 = 34; + pub const TRAP: u8 = 35; + pub const PAY_DAY: u8 = 36; + pub const MIST: u8 = 37; + pub const FOCUS_ENERGY: u8 = 38; + pub const SUBSTITUTE: u8 = 39; + pub const TRANSFORM: u8 = 40; + pub const CONVERSION: u8 = 41; + pub const METRONOME: u8 = 42; + pub const MIRROR_MOVE: u8 = 43; + pub const DISABLE: u8 = 44; + pub const LEECH_SEED: u8 = 45; + pub const DREAM_EATER: u8 = 46; +} + +/// MAP header byte size; the block array and variable sections follow. +pub const MAP_HEADER_SIZE: usize = 32; +pub const MAP_FLAG_INDOOR: u8 = 1; +pub const MAP_FLAG_DARK: u8 = 2; +pub const MAP_FLAG_NO_ESCAPE: u8 = 4; +pub const WARP_SIZE: usize = 8; +pub const SIGN_SIZE: usize = 4; +pub const ACTOR_SIZE: usize = 12; +pub const SLOT_SIZE: usize = 4; +/// Encounter slots per map. +pub const SLOT_COUNT: usize = 10; +/// Cumulative slot thresholds out of 256; rand(0..255) picks the first +/// bucket it falls under (ported from the upstream wild-encounter buckets). +pub const ENCOUNTER_BUCKETS: [u16; 10] = [51, 102, 141, 166, 191, 212, 233, 243, 253, 256]; +pub const TRAINER_HEADER_SIZE: usize = 8; +pub const TRAINER_MON_SIZE: usize = 12; +pub const TRAINER_PARTY_MAX: usize = 6; + +// --------------------------------------------------------------------------- +// Script VM — the compiled command list +// --------------------------------------------------------------------------- + +pub const SCRIPT_VERSION: u16 = 1; +pub const SCRIPT_HEADER_SIZE: usize = 8; + +/// The script verb set: the upstream Commands.lua vocabulary trimmed to +/// what the core implements natively. Unknown verbs raise `scriptHook`. +pub mod verb { + pub const END: u8 = 0; + pub const SHOW_TEXT: u8 = 1; + pub const ASK: u8 = 2; + pub const JUMP: u8 = 3; + pub const JUMP_IF_TRUE: u8 = 4; + pub const JUMP_IF_FALSE: u8 = 5; + pub const SET_FLAG: u8 = 6; + pub const CLEAR_FLAG: u8 = 7; + pub const CHECK_FLAG: u8 = 8; + pub const CHECK_ITEM: u8 = 9; + pub const GIVE_ITEM: u8 = 10; + pub const TAKE_ITEM: u8 = 11; + pub const START_BATTLE: u8 = 12; + pub const WARP: u8 = 13; + pub const WAIT: u8 = 14; + pub const MOVE_PLAYER: u8 = 15; + pub const MOVE_NPC: u8 = 16; + pub const FACE_NPC: u8 = 17; + pub const FACE_PLAYER: u8 = 18; + pub const SHOW_OBJECT: u8 = 19; + pub const HIDE_OBJECT: u8 = 20; + pub const PLAY_SOUND: u8 = 21; + pub const PLAY_CRY: u8 = 22; + pub const PLAY_MUSIC: u8 = 23; + pub const STOP_MUSIC: u8 = 24; + pub const HEAL_PARTY: u8 = 25; + pub const GIVEMON: u8 = 26; + pub const GIVE_MONEY: u8 = 27; + pub const CHECK_BATTLE_RESULT: u8 = 28; + pub const TRAINER_BATTLE: u8 = 29; + pub const OPEN_MART: u8 = 30; + pub const REPLACE_BLOCK: u8 = 31; + pub const FADE: u8 = 32; + pub const PAN_CAMERA: u8 = 33; + pub const EMOTE: u8 = 34; + pub const LABEL: u8 = 35; + pub const HOOK: u8 = 36; + pub const SET_FIELD: u8 = 37; + pub const CHOICE: u8 = 38; + pub const WAIT_FLAG: u8 = 39; + pub const TEXT_OPTS: u8 = 40; +} + +// --------------------------------------------------------------------------- +// Draw list — the backend-independent frame output +// --------------------------------------------------------------------------- + +pub const QUAD_SIZE: usize = 16; +pub const RECT_SIZE: usize = 12; +pub const QUAD_FLAG_FLIP_X: u8 = 1; +pub const QUAD_FLAG_FLIP_Y: u8 = 2; +/// Per-vertex tint; this value is "untinted". +pub const TINT_NONE: u32 = 0xffffffff; +/// Atlas page edge in pixels (CLUT8: one page is PAGE_PX^2 bytes). +pub const PAGE_PX: u32 = 256; +pub const PAGE_MAX: usize = 8; + +// --------------------------------------------------------------------------- +// Save format +// --------------------------------------------------------------------------- + +pub mod save { + pub const MAGIC: u32 = 0x5641534d; // 'MSAV' LE + pub const VERSION: u16 = 1; + pub const HEADER_SIZE: usize = 16; + /// FNV-1a 32-bit, the same constants the .pak container uses. + pub const FNV1A_OFFSET_BASIS: u32 = 0x811c9dc5; + pub const FNV1A_PRIME: u32 = 0x01000193; +} + +/// Event flags addressable by scripts. +pub const FLAG_COUNT: usize = 512; + +// --------------------------------------------------------------------------- +// Battle tuning constants (the default ruleset) +// --------------------------------------------------------------------------- + +/// Damage randomization: d = d * rand(RAND_MIN..=RAND_MAX) / 255. +pub const RAND_MIN: u32 = 217; +pub const RAND_MAX: u32 = 255; +/// Stat-stage multipliers x100, indexed `stage + 6` (index 6 = stage 0). +pub const STAGE_MULT: [u32; 13] = [25, 28, 33, 40, 50, 66, 100, 150, 200, 250, 300, 350, 400]; +/// Stats clamp here after a stage multiplication. +pub const STAT_MAX: u32 = 999; +/// Damage clamps here before the +2, then the random factor applies. +pub const DAMAGE_CLAMP: u32 = 997; +/// Both stats are quartered when either exceeds this (byte-overflow rule). +pub const STAT_SCALE_LIMIT: u32 = 255; +/// STAB is x3/2. +pub const STAB_NUM: u32 = 3; +pub const STAB_DEN: u32 = 2; +/// Type multipliers are x10 fixed point. +pub const TYPE_SCALE: u32 = 10; +pub const BALL_RATE: [u32; 4] = [255, 200, 150, 100]; +/// Status bonus added to the catch roll. +pub const CATCH_BONUS_NONE: u32 = 0; +pub const CATCH_BONUS_SLEEP_FREEZE: u32 = 25; +pub const CATCH_BONUS_OTHER: u32 = 12; diff --git a/engine/pocketmon/crates/pocketmon-core/src/surface.rs b/engine/pocketmon/crates/pocketmon-core/src/surface.rs new file mode 100644 index 00000000..4ed20be3 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/surface.rs @@ -0,0 +1,691 @@ +//! The `mon` surface: the guest -> core op boundary (docs/MON.md §3). +//! +//! Everything a guest can do to this runtime goes through [`Game::op`]. The +//! op codes and their meanings are pinned in `contracts/spec/mon-spec.ts` and +//! generated into `spec::op`; this module is the single place they turn into +//! behaviour. +//! +//! ## Why a dispatcher and not a pile of FFI functions +//! +//! A host binding (QuickJS on the PSP, a test harness here) is then a thin +//! marshalling shim over ONE function, instead of fifty hand-written trampolines +//! that each have to remember their own argument order. It also means the +//! surface can be exercised end to end in a plain `cargo test` — the alternative +//! is discovering an argument-order mistake on a console. +//! +//! ## Law 2 +//! +//! Ops are one-way writes plus cold-path queries. Nothing here calls back into +//! the guest; facts travel the other way as events, which the guest drains with +//! `events()`. + +use alloc::string::String; +use alloc::vec::Vec; + +use crate::spec; +use crate::Game; + +/// An op argument. Deliberately small: numbers, text, bytes. +#[derive(Clone, Copy, Debug, Default)] +pub enum Arg<'a> { + #[default] + None, + Int(i32), + Str(&'a str), + Bytes(&'a [u8]), +} + +impl Arg<'_> { + pub fn int(&self) -> i32 { + match self { + Arg::Int(v) => *v, + _ => 0, + } + } + + pub fn str(&self) -> &str { + match self { + Arg::Str(s) => s, + _ => "", + } + } + + pub fn bytes(&self) -> &[u8] { + match self { + Arg::Bytes(b) => b, + _ => &[], + } + } +} + +/// What an op returned. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Ret { + /// No value (the common case: an op is intent, not a question). + None, + Int(i32), + Bool(bool), + Str(String), + Bytes(Vec), +} + +impl Ret { + pub fn as_int(&self) -> i32 { + match self { + Ret::Int(v) => *v, + Ret::Bool(b) => *b as i32, + _ => 0, + } + } + + pub fn as_bool(&self) -> bool { + match self { + Ret::Bool(b) => *b, + Ret::Int(v) => *v != 0, + _ => false, + } + } +} + +/// Fetch argument `i`, or `Arg::None`. +fn arg<'a>(args: &'a [Arg<'a>], i: usize) -> Arg<'a> { + args.get(i).copied().unwrap_or(Arg::None) +} + +impl Game { + /// Execute one op from the guest. + /// + /// An unknown code is a no-op returning `Ret::None`, not a panic: op codes + /// are append-only and a guest built against a newer spec must degrade + /// rather than take the console down (the same "degraded mode" rule the + /// 2D runtime's optional ops follow). + pub fn op(&mut self, code: u32, args: &[Arg]) -> Ret { + use spec::op; + match code { + // --- content --------------------------------------------------- + op::LOAD_CONTENT => Ret::Bool(self.load_content(arg(args, 0).bytes())), + + // --- world ----------------------------------------------------- + op::ENTER_MAP => { + self.enter_map( + arg(args, 0).int() as u16, + arg(args, 1).int(), + arg(args, 2).int(), + arg(args, 3).int().clamp(0, 3) as u8, + ); + Ret::None + } + op::WARP_TO => { + self.world.warp_to( + arg(args, 0).int() as u16, + arg(args, 1).int(), + arg(args, 2).int(), + arg(args, 3).int().clamp(0, 3) as u8, + arg(args, 4).int() != 0, + ); + Ret::None + } + op::HIDE_ACTOR | op::SHOW_ACTOR => { + let slot = arg(args, 0).int().max(0) as usize; + if let Some(a) = self.world.actors.get_mut(slot) { + a.visible = code == op::SHOW_ACTOR; + } + Ret::None + } + op::MOVE_ACTOR => { + let slot = arg(args, 0).int().max(0) as usize; + let dir = arg(args, 1).int().clamp(0, 3) as u8; + let cells = arg(args, 2).int().clamp(0, 255) as u8; + if let Some(a) = self.world.actors.get_mut(slot) { + a.queue_move(dir, cells); + } + Ret::None + } + op::FACE_ACTOR => { + let slot = arg(args, 0).int().max(0) as usize; + let dir = arg(args, 1).int().clamp(0, 3) as u8; + if let Some(a) = self.world.actors.get_mut(slot) { + a.dir = dir; + } + Ret::None + } + op::SET_FLAG => { + self.player + .set_flag(arg(args, 0).int() as u16, arg(args, 1).int() != 0); + Ret::None + } + op::GET_FLAG => Ret::Bool(self.player.flag(arg(args, 0).int() as u16)), + op::SET_BLOCK => { + self.content.set_block( + arg(args, 0).int() as u16, + arg(args, 1).int(), + arg(args, 2).int(), + arg(args, 3).int().clamp(0, 255) as u8, + ); + Ret::None + } + op::SHOW_TEXT => Ret::Int(self.show_text(arg(args, 0).str())), + op::SHOW_CHOICE => { + // Options arrive as one newline-separated string: the boundary + // stays a single value, and no guest has to build an array of + // strings just to ask a yes/no question. + let a0 = arg(args, 0); + let a1 = arg(args, 1); + let prompt = a0.str(); + let joined = a1.str(); + let options: Vec<&str> = if joined.is_empty() { + Vec::new() + } else { + joined.split('\n').collect() + }; + Ret::Int(self.show_choice(prompt, &options)) + } + op::CLOSE_TEXT => { + self.text.close(); + Ret::None + } + op::SET_MODE => { + self.mode = arg(args, 0).int().clamp(0, 4) as u8; + Ret::None + } + op::PLAY_MUSIC => { + self.music = arg(args, 0).int(); + Ret::None + } + op::STOP_MUSIC => { + self.music = -1; + Ret::None + } + op::PLAY_SFX => { + self.sfx = arg(args, 0).int(); + Ret::None + } + op::PLAY_CRY => { + self.cry = arg(args, 0).int(); + Ret::None + } + + // --- party / bag ------------------------------------------------ + op::GIVEMON => { + let species = arg(args, 0).int() as u16; + let level = arg(args, 1).int().clamp(1, spec::LEVEL_MAX as i32) as u8; + match crate::mon::MonInstance::wild(&self.content, species, level, &mut self.rng) { + Some(m) => { + self.player.own(species); + match self.player.party.add(m) { + Some(slot) => Ret::Int(slot as i32), + None => Ret::Int(-1), + } + } + None => Ret::Int(-1), + } + } + op::HEAL_PARTY => { + self.player.party.heal_all(); + Ret::None + } + op::GIVE_ITEM => Ret::Bool(self.player.bag.add( + arg(args, 0).int() as u16, + arg(args, 1).int().clamp(1, 99) as u8, + )), + op::TAKE_ITEM => Ret::Bool(self.player.bag.take( + arg(args, 0).int() as u16, + arg(args, 1).int().clamp(1, 99) as u8, + )), + op::SET_PARTY_MOVE => { + let slot = arg(args, 0).int().max(0) as usize; + let idx = arg(args, 1).int().max(0) as usize; + let move_id = arg(args, 2).int() as u16; + // Split the borrow: `replace_move` reads content while the + // party is borrowed mutably out of the same struct. + let Game { player, content, .. } = self; + if let Some(m) = player.party.get_mut(slot) { + m.replace_move(content, idx, move_id); + } + Ret::None + } + + // --- battle ----------------------------------------------------- + op::START_WILD => { + self.start_wild( + arg(args, 0).int() as u16, + arg(args, 1).int().clamp(1, spec::LEVEL_MAX as i32) as u8, + ); + Ret::Bool(self.battle.is_some()) + } + op::START_TRAINER => { + self.start_trainer(arg(args, 0).int() as u16); + Ret::Bool(self.battle.is_some()) + } + op::CHOOSE_ACTION => { + let action = arg(args, 0).int().clamp(0, 3) as u8; + let Game { battle, content, rng, .. } = self; + if let Some(b) = battle { + b.choose_action(action, content, rng); + } + Ret::None + } + op::CHOOSE_MOVE => { + let idx = arg(args, 0).int().max(0) as usize; + let Game { battle, content, rng, .. } = self; + if let Some(b) = battle { + b.choose_move(idx, content, rng); + } + Ret::None + } + op::CHOOSE_ITEM => { + let item = arg(args, 0).int() as u16; + let Game { battle, content, rng, .. } = self; + if let Some(b) = battle { + b.choose_item(item, content, rng); + } + Ret::None + } + op::CHOOSE_SWITCH => { + let slot = arg(args, 0).int().max(0) as usize; + let Game { battle, content, rng, .. } = self; + if let Some(b) = battle { + b.choose_switch(slot, content, rng); + } + Ret::None + } + op::ADVANCE => { + let Game { battle, content, rng, .. } = self; + match battle { + Some(b) => Ret::Bool(b.advance(content, rng)), + None => Ret::Bool(false), + } + } + op::END_BATTLE => { + let Game { battle, content, .. } = self; + if let Some(b) = battle { + b.finish(spec::outcome::DRAW, content); + } + Ret::None + } + + // --- query ------------------------------------------------------ + op::VIEW => Ret::Bytes(self.view(arg(args, 0).int() as u32)), + op::PARTY_SLOT => Ret::Bytes(self.party_slot_view(arg(args, 0).int().max(0) as usize)), + op::TEXT => Ret::Str(String::from(self.content.string(arg(args, 0).int() as u16))), + + // --- system ----------------------------------------------------- + op::SAVE => Ret::Bytes(self.save()), + op::LOAD => Ret::Bool(self.load(arg(args, 0).bytes())), + op::HAS_SAVE => Ret::Bool(false), // storage is the host's business + op::SEED => { + let lo = arg(args, 0).int() as u32 as u64; + let hi = arg(args, 1).int() as u32 as u64; + self.seed(lo | (hi << 32)); + Ret::None + } + op::VIEWPORT => Ret::None, // fixed at spec::VIEW_W x VIEW_H for now + op::EVENTS => Ret::Bytes(self.encode_events().to_vec()), + op::FRAME_STATS => Ret::Bytes(self.stats_view()), + + _ => Ret::None, + } + } + + /// Packed snapshot for `view(kind)`. + /// + /// Little-endian, fixed layout per kind — the guest reads it with a + /// DataView. Deliberately a blob rather than an object graph: one copy + /// across the boundary instead of one property write per field. + pub fn view(&self, kind: u32) -> Vec { + let mut out = Vec::new(); + match kind { + spec::view::WORLD => { + out.extend_from_slice(&self.world.map_id.to_le_bytes()); + out.extend_from_slice(&(self.world.player().cx as i16).to_le_bytes()); + out.extend_from_slice(&(self.world.player().cy as i16).to_le_bytes()); + out.push(self.world.player().dir); + out.push(self.mode); + out.extend_from_slice(&self.world.steps.to_le_bytes()); + out.extend_from_slice(&self.world.last_outdoor.to_le_bytes()); + } + spec::view::PLAYER => { + out.extend_from_slice(&self.player.name_key.to_le_bytes()); + out.extend_from_slice(&self.player.money.to_le_bytes()); + out.push(self.player.badges); + out.push(self.player.party.len() as u8); + } + spec::view::PARTY => { + out.push(self.player.party.len() as u8); + for m in &self.player.party.mons { + out.extend_from_slice(&m.species.to_le_bytes()); + out.push(m.level); + out.push(m.status); + out.extend_from_slice(&m.hp.to_le_bytes()); + out.extend_from_slice(&m.max_hp.to_le_bytes()); + } + } + spec::view::BAG => { + out.push(self.player.bag.slots.len() as u8); + for s in &self.player.bag.slots { + out.extend_from_slice(&s.item.to_le_bytes()); + out.push(s.qty); + } + } + spec::view::BATTLE => match self.battle.as_ref() { + Some(b) => { + out.push(1); + out.push(b.phase); + out.extend_from_slice(&b.player.mon.species.to_le_bytes()); + out.extend_from_slice(&b.player.mon.hp.to_le_bytes()); + out.extend_from_slice(&b.player.mon.max_hp.to_le_bytes()); + out.extend_from_slice(&b.foe.mon.species.to_le_bytes()); + out.extend_from_slice(&b.foe.mon.hp.to_le_bytes()); + out.extend_from_slice(&b.foe.mon.max_hp.to_le_bytes()); + out.push(b.player.mon.level); + out.push(b.foe.mon.level); + } + None => out.push(0), + }, + spec::view::DEX => { + out.extend_from_slice(&(self.player.dex_seen.len() as u16).to_le_bytes()); + out.extend_from_slice(&self.player.dex_seen); + out.extend_from_slice(&(self.player.dex_owned.len() as u16).to_le_bytes()); + out.extend_from_slice(&self.player.dex_owned); + } + _ => {} + } + out + } + + /// Packed snapshot of one party slot: everything the summary screen needs. + pub fn party_slot_view(&self, slot: usize) -> Vec { + let mut out = Vec::new(); + let Some(m) = self.player.party.get(slot) else { + return out; + }; + out.extend_from_slice(&m.species.to_le_bytes()); + out.push(m.level); + out.push(m.status); + out.extend_from_slice(&m.hp.to_le_bytes()); + out.extend_from_slice(&m.max_hp.to_le_bytes()); + out.extend_from_slice(&m.exp.to_le_bytes()); + for slot in &m.moves { + out.extend_from_slice(&slot.id.to_le_bytes()); + out.push(slot.pp); + out.push(slot.pp_max); + } + out + } + + fn stats_view(&self) -> Vec { + let s = &self.stats; + let mut out = Vec::with_capacity(24); + for v in [s.tick, s.quads, s.rects, s.events, s.events_dropped, s.script_steps] { + out.extend_from_slice(&v.to_le_bytes()); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::{MapDef, Species, Tileset}; + use alloc::vec; + + fn game() -> Game { + let mut g = Game::new(); + let mut behavior = [spec::cell::WALL; spec::TILE_BEHAVIOR_BYTES]; + behavior[1] = spec::cell::FLOOR; + g.content.tilesets.push(Tileset { blocks: vec![[1u8; 16]], behavior }); + g.content.maps.insert( + 1, + MapDef { + id: 1, + width: 4, + height: 4, + tileset: 0, + blocks: vec![0; 16], + conn: [-1; 4], + ..Default::default() + }, + ); + g.content.species.insert( + 1, + Species { + id: 1, + base_hp: 45, + base_atk: 49, + base_def: 49, + base_spd: 45, + base_spc: 65, + catch_rate: 45, + base_exp: 64, + // A creature with an empty learnset is fielded with no moves + // and the battle can never end — give the fixture one. + learn_offset: 0, + learn_count: 1, + ..Default::default() + }, + ); + g.content.learn_pool.push(crate::content::Learn { level: 1, move_id: 1 }); + g.content.moves.insert( + 1, + crate::content::Move { + id: 1, + power: 40, + accuracy: 100, + pp: 35, + category: spec::category::PHYSICAL, + ..Default::default() + }, + ); + g + } + + #[test] + fn an_unknown_op_is_a_no_op_not_a_panic() { + let mut g = game(); + // Append-only codes mean a guest from the future WILL call something + // this build has never heard of. + assert_eq!(g.op(9999, &[]), Ret::None); + assert_eq!(g.op(0, &[]), Ret::None); + } + + #[test] + fn missing_arguments_read_as_zero() { + let mut g = game(); + // A guest that under-supplies must get a defined result. + assert_eq!(g.op(spec::op::GET_FLAG, &[]), Ret::Bool(false)); + g.op(spec::op::SET_FLAG, &[Arg::Int(3)]); // no value: clears + assert_eq!(g.op(spec::op::GET_FLAG, &[Arg::Int(3)]), Ret::Bool(false)); + } + + #[test] + fn flags_round_trip_through_the_surface() { + let mut g = game(); + g.op(spec::op::SET_FLAG, &[Arg::Int(7), Arg::Int(1)]); + assert_eq!(g.op(spec::op::GET_FLAG, &[Arg::Int(7)]), Ret::Bool(true)); + g.op(spec::op::SET_FLAG, &[Arg::Int(7), Arg::Int(0)]); + assert_eq!(g.op(spec::op::GET_FLAG, &[Arg::Int(7)]), Ret::Bool(false)); + } + + #[test] + fn the_bag_is_reachable_from_the_guest() { + let mut g = game(); + assert_eq!(g.op(spec::op::GIVE_ITEM, &[Arg::Int(4), Arg::Int(3)]), Ret::Bool(true)); + assert_eq!(g.player.bag.count(4), 3); + assert_eq!(g.op(spec::op::TAKE_ITEM, &[Arg::Int(4), Arg::Int(2)]), Ret::Bool(true)); + assert_eq!(g.player.bag.count(4), 1); + assert_eq!(g.op(spec::op::TAKE_ITEM, &[Arg::Int(4), Arg::Int(9)]), Ret::Bool(false)); + } + + #[test] + fn givemon_reports_the_slot_and_an_unknown_species_fails() { + let mut g = game(); + assert_eq!(g.op(spec::op::GIVEMON, &[Arg::Int(1), Arg::Int(5)]), Ret::Int(0)); + assert_eq!(g.op(spec::op::GIVEMON, &[Arg::Int(1), Arg::Int(5)]), Ret::Int(1)); + assert_eq!(g.op(spec::op::GIVEMON, &[Arg::Int(999), Arg::Int(5)]), Ret::Int(-1)); + assert!(g.player.owned(1)); + } + + #[test] + fn a_wild_battle_can_be_driven_entirely_through_ops() { + let mut g = game(); + g.op(spec::op::ENTER_MAP, &[Arg::Int(1), Arg::Int(2), Arg::Int(2), Arg::Int(0)]); + g.op(spec::op::GIVEMON, &[Arg::Int(1), Arg::Int(30)]); + assert_eq!(g.op(spec::op::START_WILD, &[Arg::Int(1), Arg::Int(3)]), Ret::Bool(true)); + + // Walk the intro, then fight, all through the surface. + for _ in 0..400 { + if g.battle.is_none() { + break; + } + let phase = g.battle.as_ref().map(|b| b.phase).unwrap_or(0); + match phase { + spec::phase::CHOOSE_ACTION => { + g.op(spec::op::CHOOSE_ACTION, &[Arg::Int(spec::action::FIGHT as i32)]); + } + spec::phase::CHOOSE_MOVE => { + g.op(spec::op::CHOOSE_MOVE, &[Arg::Int(0)]); + } + spec::phase::ENDED => break, + _ => { + if let Some(b) = g.battle.as_mut() { + b.msg_hold = 0; + } + g.op(spec::op::ADVANCE, &[]); + } + } + } + assert!(g.battle.as_ref().map(|b| b.outcome.is_some()).unwrap_or(true)); + } + + #[test] + fn views_are_packed_little_endian_and_sized_by_content() { + let mut g = game(); + g.op(spec::op::ENTER_MAP, &[Arg::Int(1), Arg::Int(3), Arg::Int(2), Arg::Int(1)]); + let Ret::Bytes(world) = g.op(spec::op::VIEW, &[Arg::Int(spec::view::WORLD as i32)]) else { + panic!("world view"); + }; + assert_eq!(u16::from_le_bytes([world[0], world[1]]), 1); + assert_eq!(i16::from_le_bytes([world[2], world[3]]), 3); + assert_eq!(i16::from_le_bytes([world[4], world[5]]), 2); + assert_eq!(world[6], spec::dir::UP); + + // The party view grows with the party. + let Ret::Bytes(empty) = g.op(spec::op::VIEW, &[Arg::Int(spec::view::PARTY as i32)]) else { + panic!("party view"); + }; + assert_eq!(empty[0], 0); + g.op(spec::op::GIVEMON, &[Arg::Int(1), Arg::Int(5)]); + let Ret::Bytes(one) = g.op(spec::op::VIEW, &[Arg::Int(spec::view::PARTY as i32)]) else { + panic!("party view"); + }; + assert_eq!(one[0], 1); + assert!(one.len() > empty.len()); + } + + #[test] + fn an_unknown_view_kind_returns_nothing() { + let mut g = game(); + assert_eq!(g.op(spec::op::VIEW, &[Arg::Int(99)]), Ret::Bytes(Vec::new())); + } + + #[test] + fn a_party_slot_view_is_empty_for_a_slot_that_is_not_there() { + let mut g = game(); + assert_eq!(g.op(spec::op::PARTY_SLOT, &[Arg::Int(0)]), Ret::Bytes(Vec::new())); + g.op(spec::op::GIVEMON, &[Arg::Int(1), Arg::Int(5)]); + let Ret::Bytes(slot) = g.op(spec::op::PARTY_SLOT, &[Arg::Int(0)]) else { + panic!("slot view"); + }; + assert!(!slot.is_empty()); + } + + #[test] + fn save_and_load_round_trip_through_the_surface() { + let mut g = game(); + g.op(spec::op::ENTER_MAP, &[Arg::Int(1), Arg::Int(2), Arg::Int(3), Arg::Int(0)]); + g.op(spec::op::GIVEMON, &[Arg::Int(1), Arg::Int(9)]); + g.op(spec::op::SET_FLAG, &[Arg::Int(11), Arg::Int(1)]); + let Ret::Bytes(blob) = g.op(spec::op::SAVE, &[]) else { panic!("save") }; + assert!(!blob.is_empty()); + + let mut fresh = game(); + assert_eq!(fresh.op(spec::op::LOAD, &[Arg::Bytes(&blob)]), Ret::Bool(true)); + assert_eq!(fresh.player.party.len(), 1); + assert_eq!(fresh.player.party.get(0).unwrap().level, 9); + assert!(fresh.player.flag(11)); + assert_eq!(fresh.world.map_id, 1); + assert_eq!((fresh.world.player().cx, fresh.world.player().cy), (2, 3)); + } + + #[test] + fn loading_rubbish_changes_nothing() { + let mut g = game(); + g.op(spec::op::GIVEMON, &[Arg::Int(1), Arg::Int(5)]); + assert_eq!(g.op(spec::op::LOAD, &[Arg::Bytes(b"not a save")]), Ret::Bool(false)); + assert_eq!(g.player.party.len(), 1, "the live game survived"); + } + + #[test] + fn seeding_is_reproducible_through_the_surface() { + let run = || { + let mut g = game(); + g.op(spec::op::SEED, &[Arg::Int(0x1234), Arg::Int(0)]); + (0..20).map(|_| g.rng.byte()).collect::>() + }; + assert_eq!(run(), run()); + } + + #[test] + fn events_drain_through_the_surface_and_do_not_repeat() { + let mut g = game(); + g.op(spec::op::ENTER_MAP, &[Arg::Int(1), Arg::Int(2), Arg::Int(2), Arg::Int(0)]); + g.events.push(crate::MonEvent { + kind: spec::event::SIGN, + a: 0, + b: 1, + c: 0, + d: 0, + }); + let Ret::Bytes(first) = g.op(spec::op::EVENTS, &[]) else { panic!("events") }; + assert_eq!(first.len(), spec::EVENT_SIZE); + let Ret::Bytes(second) = g.op(spec::op::EVENTS, &[]) else { panic!("events") }; + assert!(second.is_empty(), "a drained batch does not come back"); + } + + #[test] + fn text_comes_back_by_key() { + let mut g = game(); + g.content.strings = vec![String::new(), String::from("HELLO")]; + assert_eq!(g.op(spec::op::TEXT, &[Arg::Int(1)]), Ret::Str(String::from("HELLO"))); + assert_eq!(g.op(spec::op::TEXT, &[Arg::Int(99)]), Ret::Str(String::new())); + } + + #[test] + fn a_choice_takes_its_options_as_one_newline_joined_string() { + let mut g = game(); + for cp in 32u32..127 { + g.content.glyphs.push(crate::content::Glyph { + codepoint: cp, + u: 0, + v: 0, + w: 8, + h: 8, + advance: 8, + }); + } + g.content.glyphs.sort_unstable_by_key(|glyph| glyph.codepoint); + let handle = g.op(spec::op::SHOW_CHOICE, &[Arg::Str("WELL?"), Arg::Str("YES\nNO")]); + assert!(handle.as_int() > 0); + assert!(g.text.active()); + assert_eq!(g.text.choice().map(|c| c.options.len()), Some(2)); + } + + #[test] + fn frame_stats_are_six_little_endian_words() { + let mut g = game(); + g.tick(0); + let Ret::Bytes(stats) = g.op(spec::op::FRAME_STATS, &[]) else { panic!("stats") }; + assert_eq!(stats.len(), 24); + assert_eq!(u32::from_le_bytes([stats[0], stats[1], stats[2], stats[3]]), 1); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/text.rs b/engine/pocketmon/crates/pocketmon-core/src/text.rs new file mode 100644 index 00000000..339f15fb --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/text.rs @@ -0,0 +1,509 @@ +//! The dialogue box: wrapping, the typewriter reveal, paging and choices. +//! +//! Ported from upstream `src/render/TextBox.lua`. The control codes are the +//! original engine's, because every line of dialogue in the content is written +//! against them: +//! +//! | code | meaning | +//! | --- | --- | +//! | `\n` | hard line break within the page | +//! | `\v` | scroll: the box advances one line, keeping the last line visible | +//! | `\f` | page break: wait for A, then clear and continue | +//! +//! Wrapping is greedy by word and measured through the loaded font, so a +//! translated or modded font changes the line breaks without touching content. + +use alloc::string::{String, ToString}; +use alloc::vec::Vec; + +use crate::content::Content; +use crate::event::{EventQueue, MonEvent}; +use crate::spec; + +/// Lines visible in the box at once. +pub const LINES: usize = 3; +/// Inner width available to glyphs, in logical pixels. +pub const BOX_INNER_W: i32 = spec::VIEW_W - 16; +/// Frames between revealed characters (the typewriter speed). +pub const TYPE_DELAY: u8 = 2; +/// Fallback advance for a codepoint the font does not carry. +const DEFAULT_ADVANCE: u8 = 8; +/// `\v` — scroll one line. Rust has no `\v` escape, so the control characters +/// are spelled out; content authors still write them as `\v` / `\f` in TS. +pub const VERT_TAB: char = '\u{0b}'; +/// `\f` — page break. +pub const FORM_FEED: char = '\u{0c}'; + +/// One laid-out page: up to `LINES` lines of text. +#[derive(Clone, Debug, Default)] +pub struct Page { + pub lines: Vec, +} + +impl Page { + /// Total characters on the page — the typewriter's target. + fn char_count(&self) -> usize { + self.lines.iter().map(|l| l.chars().count()).sum() + } +} + +/// A pending choice prompt. +#[derive(Clone, Debug, Default)] +pub struct Choice { + pub options: Vec, + pub cursor: u8, +} + +/// The dialogue box state machine. +#[derive(Clone, Debug, Default)] +pub struct TextBox { + pages: Vec, + page: usize, + /// Characters revealed on the current page. + revealed: usize, + delay: u8, + handle: i32, + next_handle: i32, + open: bool, + choice: Option, + /// Set once the current page is fully revealed (the "▼" prompt shows). + pub page_done: bool, +} + +impl TextBox { + pub fn new() -> Self { + TextBox { next_handle: 1, ..Default::default() } + } + + pub fn active(&self) -> bool { + self.open + } + + pub fn handle(&self) -> i32 { + self.handle + } + + /// The page currently on screen. + pub fn current(&self) -> Option<&Page> { + self.pages.get(self.page) + } + + /// How many characters of the current page are visible. + pub fn revealed(&self) -> usize { + self.revealed + } + + pub fn choice(&self) -> Option<&Choice> { + self.choice.as_ref() + } + + /// Show a string. Returns a handle the guest matches against `textDone`. + pub fn show(&mut self, content: &Content, s: &str) -> i32 { + self.pages = layout(content, s); + self.page = 0; + self.revealed = 0; + self.delay = 0; + self.open = !self.pages.is_empty(); + self.page_done = false; + self.choice = None; + self.handle = self.next_handle; + self.next_handle = self.next_handle.wrapping_add(1).max(1); + self.handle + } + + /// Show a string that ends in a choice. The choice appears once the last + /// page is fully revealed. + pub fn show_choice(&mut self, content: &Content, s: &str, options: &[&str]) -> i32 { + let h = self.show(content, s); + self.choice = Some(Choice { + options: options.iter().map(|o| o.to_string()).collect(), + cursor: 0, + }); + h + } + + /// Close immediately, without emitting a completion event. + pub fn close(&mut self) { + self.open = false; + self.pages.clear(); + self.choice = None; + self.page_done = false; + self.revealed = 0; + } + + /// Advance one frame. + pub fn tick(&mut self, pressed: u32, events: &mut EventQueue) { + if !self.open { + return; + } + let total = self.current().map(Page::char_count).unwrap_or(0); + + // Typewriter. + if self.revealed < total { + // A press fast-forwards the reveal rather than skipping the page: + // the original's "hold A to speed up text" without dropping a line. + if pressed & (spec::btn::A | spec::btn::B) != 0 { + self.revealed = total; + } else if self.delay == 0 { + self.revealed += 1; + self.delay = TYPE_DELAY; + } else { + self.delay -= 1; + } + self.page_done = self.revealed >= total; + return; + } + self.page_done = true; + + // A choice owns the input once its text is up. + if let Some(c) = self.choice.as_mut() { + let n = c.options.len() as u8; + if n > 0 { + if pressed & spec::btn::UP != 0 { + c.cursor = (c.cursor + n - 1) % n; + } + if pressed & spec::btn::DOWN != 0 { + c.cursor = (c.cursor + 1) % n; + } + if pressed & spec::btn::A != 0 { + let idx = c.cursor as i32; + let h = self.handle; + self.close(); + events.push(MonEvent { + kind: spec::event::CHOICE_DONE, + a: 0, + b: h, + c: idx, + d: 0, + }); + } + // B cancels to the LAST option, the universal "no" convention. + if pressed & spec::btn::B != 0 { + let idx = (n - 1) as i32; + let h = self.handle; + self.close(); + events.push(MonEvent { + kind: spec::event::CHOICE_DONE, + a: 0, + b: h, + c: idx, + d: 0, + }); + } + } + return; + } + + if pressed & spec::btn::A != 0 { + if self.page + 1 < self.pages.len() { + self.page += 1; + self.revealed = 0; + self.delay = 0; + self.page_done = false; + } else { + let h = self.handle; + self.close(); + events.push(MonEvent { + kind: spec::event::TEXT_DONE, + a: 0, + b: h, + c: 0, + d: 0, + }); + } + } + } +} + +/// Advance width of one character through the loaded font. +pub fn advance(content: &Content, ch: char) -> i32 { + content + .glyph(ch as u32) + .map(|g| g.advance) + .unwrap_or(DEFAULT_ADVANCE) as i32 +} + +/// Pixel width of a string. +pub fn measure(content: &Content, s: &str) -> i32 { + s.chars().map(|c| advance(content, c)).sum() +} + +/// Lay a string out into pages of wrapped lines. +/// +/// Greedy word wrap: a word that does not fit starts the next line; a word +/// longer than the whole box is broken mid-word rather than overflowing. +pub fn layout(content: &Content, s: &str) -> Vec { + let mut pages: Vec = Vec::new(); + let mut page = Page::default(); + let mut line = String::new(); + let mut line_w = 0; + + // Push the working line into the page; start a new page when full. + fn flush_line(pages: &mut Vec, page: &mut Page, line: &mut String, line_w: &mut i32) { + page.lines.push(core::mem::take(line)); + *line_w = 0; + if page.lines.len() >= LINES { + pages.push(core::mem::take(page)); + } + } + + let mut chars = s.chars().peekable(); + while let Some(ch) = chars.next() { + match ch { + '\n' => flush_line(&mut pages, &mut page, &mut line, &mut line_w), + VERT_TAB => { + // Scroll: end the line and, if the page is full, roll it over + // keeping the last line as context. + flush_line(&mut pages, &mut page, &mut line, &mut line_w); + if page.lines.len() >= LINES { + pages.push(core::mem::take(&mut page)); + } + } + FORM_FEED => { + if !line.is_empty() { + flush_line(&mut pages, &mut page, &mut line, &mut line_w); + } + if !page.lines.is_empty() { + pages.push(core::mem::take(&mut page)); + } + } + ' ' => { + // Measure the upcoming word to decide whether the space fits. + let mut word_w = 0; + let mut probe = chars.clone(); + while let Some(&c) = probe.peek() { + if c == ' ' || c == '\n' || c == VERT_TAB || c == FORM_FEED { + break; + } + word_w += advance(content, c); + probe.next(); + } + let space_w = advance(content, ' '); + if line_w + space_w + word_w > BOX_INNER_W && !line.is_empty() { + flush_line(&mut pages, &mut page, &mut line, &mut line_w); + } else if !line.is_empty() { + line.push(' '); + line_w += space_w; + } + } + _ => { + let w = advance(content, ch); + if line_w + w > BOX_INNER_W && !line.is_empty() { + flush_line(&mut pages, &mut page, &mut line, &mut line_w); + } + line.push(ch); + line_w += w; + } + } + } + if !line.is_empty() { + page.lines.push(line); + } + if !page.lines.is_empty() { + pages.push(page); + } + pages +} + +/// Format an unsigned number into a fixed-width, space-padded string — the +/// HUD's money and HP counters, without a formatter in no_std. +pub fn pad_num(value: u32, width: usize) -> String { + let mut s = value.to_string(); + while s.len() < width { + s.insert(0, ' '); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::Glyph; + + /// A font where every glyph advances 8 px, so widths are predictable. + fn font() -> Content { + let mut c = Content::new(); + for cp in 32u32..127 { + c.glyphs.push(Glyph { codepoint: cp, u: 0, v: 0, w: 8, h: 8, advance: 8 }); + } + c.glyphs.sort_unstable_by_key(|g| g.codepoint); + c.font_line_height = 10; + c + } + + #[test] + fn short_text_is_one_page_one_line() { + let c = font(); + let pages = layout(&c, "HELLO"); + assert_eq!(pages.len(), 1); + assert_eq!(pages[0].lines, alloc::vec!["HELLO"]); + } + + #[test] + fn wrapping_breaks_on_words_not_mid_word() { + let c = font(); + // 28 glyphs fit per line (224 / 8). + let pages = layout(&c, "AAAA BBBB CCCC DDDD EEEE FFFF GGGG"); + assert!(pages[0].lines.len() >= 2); + for line in &pages[0].lines { + assert!(measure(&c, line) <= BOX_INNER_W, "line overflowed: {line}"); + assert!(!line.starts_with(' '), "leading space: {line:?}"); + } + // No word was split. + let rejoined = pages[0].lines.join(" "); + assert!(rejoined.contains("AAAA") && rejoined.contains("GGGG")); + } + + #[test] + fn an_overlong_word_is_broken_rather_than_overflowing() { + let c = font(); + let long = "X".repeat(100); + let pages = layout(&c, &long); + for p in &pages { + for line in &p.lines { + assert!(measure(&c, line) <= BOX_INNER_W); + } + } + let total: usize = pages.iter().flat_map(|p| p.lines.iter()).map(|l| l.len()).sum(); + assert_eq!(total, 100, "no characters lost"); + } + + #[test] + fn form_feed_starts_a_new_page() { + let c = font(); + let pages = layout(&c, "PAGE ONE\u{0c}PAGE TWO"); + assert_eq!(pages.len(), 2); + assert_eq!(pages[0].lines, alloc::vec!["PAGE ONE"]); + assert_eq!(pages[1].lines, alloc::vec!["PAGE TWO"]); + } + + #[test] + fn newline_breaks_a_line_within_a_page() { + let c = font(); + let pages = layout(&c, "ONE\nTWO"); + assert_eq!(pages.len(), 1); + assert_eq!(pages[0].lines, alloc::vec!["ONE", "TWO"]); + } + + #[test] + fn a_full_page_rolls_over() { + let c = font(); + let pages = layout(&c, "L1\nL2\nL3\nL4"); + assert_eq!(pages.len(), 2); + assert_eq!(pages[0].lines.len(), LINES); + assert_eq!(pages[1].lines, alloc::vec!["L4"]); + } + + #[test] + fn the_typewriter_reveals_then_waits_for_a() { + let c = font(); + let mut t = TextBox::new(); + let mut ev = EventQueue::new(); + let h = t.show(&c, "HI"); + assert!(t.active()); + assert_eq!(t.revealed(), 0); + // Two characters at TYPE_DELAY frames apart. + for _ in 0..(2 * (TYPE_DELAY as usize + 1)) { + t.tick(0, &mut ev); + } + assert_eq!(t.revealed(), 2); + assert!(t.page_done); + assert!(ev.is_empty(), "the box waits for A before closing"); + t.tick(spec::btn::A, &mut ev); + assert!(!t.active()); + let done = ev.find(spec::event::TEXT_DONE).copied().expect("textDone"); + assert_eq!(done.b, h); + } + + #[test] + fn a_press_fast_forwards_the_reveal_without_skipping() { + let c = font(); + let mut t = TextBox::new(); + let mut ev = EventQueue::new(); + t.show(&c, "A LONGER LINE OF DIALOGUE"); + t.tick(spec::btn::A, &mut ev); + assert!(t.page_done, "the whole page is revealed"); + assert!(t.active(), "but the box is still open"); + assert!(ev.is_empty()); + } + + #[test] + fn paging_walks_every_page_before_closing() { + let c = font(); + let mut t = TextBox::new(); + let mut ev = EventQueue::new(); + t.show(&c, "ONE\u{0c}TWO\u{0c}THREE"); + for expected in ["ONE", "TWO", "THREE"] { + t.tick(spec::btn::A, &mut ev); // fast-forward + assert_eq!(t.current().unwrap().lines[0], expected); + assert!(t.active()); + t.tick(spec::btn::A, &mut ev); // advance + } + assert!(!t.active()); + assert_eq!(ev.peek().iter().filter(|e| e.kind == spec::event::TEXT_DONE).count(), 1); + } + + #[test] + fn choices_report_the_selected_index() { + let c = font(); + let mut t = TextBox::new(); + let mut ev = EventQueue::new(); + let h = t.show_choice(&c, "WELL?", &["YES", "NO"]); + t.tick(spec::btn::A, &mut ev); // reveal + assert!(t.choice().is_some()); + t.tick(spec::btn::DOWN, &mut ev); + assert_eq!(t.choice().unwrap().cursor, 1); + t.tick(spec::btn::A, &mut ev); + let done = ev.find(spec::event::CHOICE_DONE).copied().expect("choiceDone"); + assert_eq!((done.b, done.c), (h, 1)); + assert!(!t.active()); + } + + #[test] + fn b_cancels_a_choice_to_the_last_option() { + let c = font(); + let mut t = TextBox::new(); + let mut ev = EventQueue::new(); + t.show_choice(&c, "WELL?", &["YES", "NO"]); + t.tick(spec::btn::A, &mut ev); + t.tick(spec::btn::B, &mut ev); + assert_eq!(ev.find(spec::event::CHOICE_DONE).unwrap().c, 1); + } + + #[test] + fn cursor_wraps_both_ways() { + let c = font(); + let mut t = TextBox::new(); + let mut ev = EventQueue::new(); + t.show_choice(&c, "?", &["A", "B", "C"]); + t.tick(spec::btn::A, &mut ev); + t.tick(spec::btn::UP, &mut ev); + assert_eq!(t.choice().unwrap().cursor, 2); + t.tick(spec::btn::DOWN, &mut ev); + assert_eq!(t.choice().unwrap().cursor, 0); + } + + #[test] + fn handles_are_unique_per_box() { + let c = font(); + let mut t = TextBox::new(); + let a = t.show(&c, "ONE"); + let b = t.show(&c, "TWO"); + assert_ne!(a, b); + assert!(a > 0 && b > 0); + } + + #[test] + fn empty_text_does_not_open_a_box() { + let c = font(); + let mut t = TextBox::new(); + t.show(&c, ""); + assert!(!t.active()); + } + + #[test] + fn numbers_pad_to_width() { + assert_eq!(pad_num(7, 3), " 7"); + assert_eq!(pad_num(1234, 3), "1234"); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/world/actor.rs b/engine/pocketmon/crates/pocketmon-core/src/world/actor.rs new file mode 100644 index 00000000..22b0747c --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/world/actor.rs @@ -0,0 +1,396 @@ +//! Grid-locked actors: the player and every NPC. +//! +//! An actor is always *logically* on a cell. A step is an animation between +//! two cells that takes a fixed number of frames; the actor's cell only +//! changes when the step lands. Keeping the logical position quantized is what +//! makes collision, interaction and encounters exact — nothing in the core +//! ever asks "which cell is this actor roughly on". +//! +//! Ported from upstream `src/world/Player.lua` + `src/world/NPC.lua`. + +use crate::spec; + +/// Frames one cell of walking takes. 16 frames at 60 Hz is the handheld's +/// walk speed; the bike halves it. +pub const WALK_FRAMES: u16 = 16; +/// Frames a ledge hop takes (two cells of travel in one move). +pub const HOP_FRAMES: u16 = 24; +/// Frames of the "turn in place" pause when an actor only changes facing. +pub const TURN_FRAMES: u16 = 6; +/// The walk cycle: which of the three walk poses to show, by quarter-step. +/// 0 = stand, 1 = left foot, 2 = right foot. +const WALK_CYCLE: [u8; 4] = [1, 0, 2, 0]; + +/// A queued scripted movement: `count` steps in `dir`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Queued { + pub dir: u8, + pub count: u8, +} + +/// One actor on the current map. +#[derive(Clone, Debug)] +pub struct Actor { + pub active: bool, + pub visible: bool, + /// The cell the actor is standing on (or stepping *from* while moving). + pub cx: i32, + pub cy: i32, + pub dir: u8, + pub moving: bool, + /// Frames elapsed into the current step. + pub prog: u16, + /// Frames the current step takes. + pub total: u16, + /// A ledge hop covers two cells and arcs. + pub hopping: bool, + /// Frames left of a turn-in-place pause. + pub turning: u16, + pub sprite: u8, + pub behavior: u8, + pub text_key: u16, + pub trainer_id: i16, + pub flag_gate: u16, + /// Frames until the wander AI rolls again. + pub wander_cd: u16, + /// Scripted movement queue; scripts block until it drains. + pub queue: [Queued; 8], + pub queue_len: u8, + /// Whether the actor is mid-script (suppresses wandering). + pub scripted: bool, +} + +impl Default for Actor { + fn default() -> Self { + Actor { + active: false, + visible: true, + cx: 0, + cy: 0, + dir: spec::dir::DOWN, + moving: false, + prog: 0, + total: WALK_FRAMES, + hopping: false, + turning: 0, + sprite: 0, + behavior: spec::behavior::STILL, + text_key: 0, + trainer_id: -1, + flag_gate: 0xffff, + wander_cd: 0, + queue: [Queued::default(); 8], + queue_len: 0, + scripted: false, + } + } +} + +impl Actor { + /// The cell this actor is heading to (its own cell when idle). + pub fn target(&self) -> (i32, i32) { + if !self.moving { + return (self.cx, self.cy); + } + let n = if self.hopping { 2 } else { 1 }; + let (mut x, mut y) = (self.cx, self.cy); + for _ in 0..n { + let (sx, sy) = super::map::step(x, y, self.dir); + x = sx; + y = sy; + } + (x, y) + } + + /// The cell the actor *occupies* for collision purposes: once a step is + /// underway the destination is reserved, so two NPCs cannot walk into the + /// same cell from opposite sides. + pub fn occupied(&self) -> (i32, i32) { + self.target() + } + + /// Sub-cell pixel position for rendering: the interpolated top-left of the + /// actor's cell, in world pixels. + pub fn pixel_pos(&self) -> (i32, i32) { + let base_x = self.cx * spec::CELL_PX; + let base_y = self.cy * spec::CELL_PX; + if !self.moving || self.total == 0 { + return (base_x, base_y); + } + let (tx, ty) = self.target(); + let dx = tx * spec::CELL_PX - base_x; + let dy = ty * spec::CELL_PX - base_y; + // Integer lerp: no float anywhere in the core. + let num = self.prog as i32; + let den = self.total as i32; + (base_x + dx * num / den, base_y + dy * num / den) + } + + /// Extra vertical offset of a ledge hop's arc, in pixels (negative = up). + /// + /// A symmetric parabola over the step, peaking at 8 px — enough to read as + /// a hop at 2x zoom without leaving the tile above. + pub fn hop_arc(&self) -> i32 { + if !self.hopping || self.total == 0 { + return 0; + } + let t = self.prog as i32; + let n = self.total as i32; + // 4 * h * t * (n - t) / n^2, h = 8 + -(32 * t * (n - t)) / (n * n) + } + + /// Which walk pose to draw (0 = stand, 1/2 = alternating feet). + pub fn anim_frame(&self) -> u8 { + if !self.moving || self.total == 0 { + return 0; + } + let quarter = (self.prog as u32 * 4 / self.total as u32).min(3) as usize; + WALK_CYCLE[quarter] + } + + /// Begin a step in `dir`. The caller has already proven the destination is + /// standable; this only starts the animation. + pub fn begin_step(&mut self, dir: u8, hop: bool) { + self.dir = dir; + self.moving = true; + self.hopping = hop; + self.prog = 0; + self.total = if hop { HOP_FRAMES } else { WALK_FRAMES }; + self.turning = 0; + } + + /// Turn in place without moving. + pub fn begin_turn(&mut self, dir: u8) { + self.dir = dir; + self.turning = TURN_FRAMES; + } + + /// Advance one frame. Returns true on the frame the actor *lands* on a new + /// cell — the trigger point for encounters, warps and script steps. + pub fn advance(&mut self) -> bool { + if self.turning > 0 { + self.turning -= 1; + return false; + } + if !self.moving { + return false; + } + self.prog += 1; + if self.prog < self.total { + return false; + } + let (tx, ty) = self.target(); + self.cx = tx; + self.cy = ty; + self.moving = false; + self.hopping = false; + self.prog = 0; + self.total = WALK_FRAMES; + true + } + + /// Is the actor free to accept a new command? + pub fn idle(&self) -> bool { + !self.moving && self.turning == 0 + } + + /// Queue `count` scripted steps in `dir`. Silently truncates past capacity + /// — a script asking for a 9-leg walk is a content bug, not a crash. + pub fn queue_move(&mut self, dir: u8, count: u8) { + if self.queue_len as usize >= self.queue.len() { + return; + } + self.queue[self.queue_len as usize] = Queued { dir, count }; + self.queue_len += 1; + self.scripted = true; + } + + /// Pop the next queued direction, if any. + pub fn next_queued(&mut self) -> Option { + if self.queue_len == 0 { + self.scripted = false; + return None; + } + let head = &mut self.queue[0]; + let dir = head.dir; + head.count = head.count.saturating_sub(1); + if head.count == 0 { + for i in 1..self.queue_len as usize { + self.queue[i - 1] = self.queue[i]; + } + self.queue_len -= 1; + } + Some(dir) + } + + pub fn clear_queue(&mut self) { + self.queue_len = 0; + self.scripted = false; + } + + /// Has this actor finished everything a script asked of it? + pub fn script_done(&self) -> bool { + self.queue_len == 0 && self.idle() + } + + /// Face the cell an actor at (px, py) is standing on. + pub fn face_toward(&mut self, px: i32, py: i32) { + let dx = px - self.cx; + let dy = py - self.cy; + self.dir = if dx.abs() > dy.abs() { + if dx < 0 { + spec::dir::LEFT + } else { + spec::dir::RIGHT + } + } else if dy < 0 { + spec::dir::UP + } else { + spec::dir::DOWN + }; + } +} + +/// The opposite of a direction. +pub fn opposite(dir: u8) -> u8 { + match dir { + spec::dir::UP => spec::dir::DOWN, + spec::dir::DOWN => spec::dir::UP, + spec::dir::LEFT => spec::dir::RIGHT, + _ => spec::dir::LEFT, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn walker() -> Actor { + Actor { active: true, cx: 5, cy: 5, ..Default::default() } + } + + #[test] + fn a_step_lands_exactly_once_after_walk_frames() { + let mut a = walker(); + a.begin_step(spec::dir::RIGHT, false); + let mut landings = 0; + for _ in 0..WALK_FRAMES { + if a.advance() { + landings += 1; + } + } + assert_eq!(landings, 1); + assert_eq!((a.cx, a.cy), (6, 5)); + assert!(!a.moving); + // Idle advances never land again. + assert!(!a.advance()); + } + + #[test] + fn the_destination_is_reserved_while_stepping() { + let mut a = walker(); + assert_eq!(a.occupied(), (5, 5)); + a.begin_step(spec::dir::UP, false); + assert_eq!(a.occupied(), (5, 4), "the target cell is claimed immediately"); + } + + #[test] + fn pixel_position_interpolates_and_lands_on_the_grid() { + let mut a = walker(); + a.begin_step(spec::dir::RIGHT, false); + assert_eq!(a.pixel_pos(), (5 * spec::CELL_PX, 5 * spec::CELL_PX)); + for _ in 0..WALK_FRAMES / 2 { + a.advance(); + } + let (x, _) = a.pixel_pos(); + assert_eq!(x, 5 * spec::CELL_PX + spec::CELL_PX / 2); + for _ in 0..WALK_FRAMES / 2 { + a.advance(); + } + assert_eq!(a.pixel_pos(), (6 * spec::CELL_PX, 5 * spec::CELL_PX)); + } + + #[test] + fn a_hop_covers_two_cells_and_arcs_upward() { + let mut a = walker(); + a.begin_step(spec::dir::DOWN, true); + assert_eq!(a.target(), (5, 7)); + let mut peak = 0; + for _ in 0..HOP_FRAMES { + peak = peak.min(a.hop_arc()); + a.advance(); + } + assert_eq!((a.cx, a.cy), (5, 7)); + assert!(peak < 0 && peak >= -8, "arc peaked at {peak}"); + assert_eq!(a.hop_arc(), 0, "the arc is flat once landed"); + } + + #[test] + fn turning_costs_frames_but_does_not_move() { + let mut a = walker(); + a.begin_turn(spec::dir::LEFT); + assert_eq!(a.dir, spec::dir::LEFT); + assert!(!a.idle()); + for _ in 0..TURN_FRAMES { + assert!(!a.advance()); + } + assert!(a.idle()); + assert_eq!((a.cx, a.cy), (5, 5)); + } + + #[test] + fn walk_cycle_alternates_feet_and_rests_when_idle() { + let mut a = walker(); + assert_eq!(a.anim_frame(), 0); + a.begin_step(spec::dir::DOWN, false); + let mut seen = alloc::vec::Vec::new(); + for _ in 0..WALK_FRAMES { + seen.push(a.anim_frame()); + a.advance(); + } + assert!(seen.contains(&1) && seen.contains(&2), "both feet: {seen:?}"); + assert_eq!(a.anim_frame(), 0); + } + + #[test] + fn queued_moves_drain_in_order() { + let mut a = walker(); + a.queue_move(spec::dir::UP, 2); + a.queue_move(spec::dir::LEFT, 1); + assert!(!a.script_done()); + assert_eq!(a.next_queued(), Some(spec::dir::UP)); + assert_eq!(a.next_queued(), Some(spec::dir::UP)); + assert_eq!(a.next_queued(), Some(spec::dir::LEFT)); + assert_eq!(a.next_queued(), None); + assert!(a.script_done()); + } + + #[test] + fn the_queue_truncates_instead_of_overflowing() { + let mut a = walker(); + for _ in 0..32 { + a.queue_move(spec::dir::UP, 1); + } + assert_eq!(a.queue_len as usize, a.queue.len()); + } + + #[test] + fn facing_prefers_the_dominant_axis() { + let mut a = walker(); + a.face_toward(9, 6); + assert_eq!(a.dir, spec::dir::RIGHT); + a.face_toward(5, 1); + assert_eq!(a.dir, spec::dir::UP); + // On a tie the vertical axis wins (matches the original's check order). + a.face_toward(6, 6); + assert_eq!(a.dir, spec::dir::DOWN); + } + + #[test] + fn opposites_round_trip() { + for d in [spec::dir::UP, spec::dir::DOWN, spec::dir::LEFT, spec::dir::RIGHT] { + assert_eq!(opposite(opposite(d)), d); + } + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/world/map.rs b/engine/pocketmon/crates/pocketmon-core/src/world/map.rs new file mode 100644 index 00000000..3926ed6d --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/world/map.rs @@ -0,0 +1,419 @@ +//! Cell queries over a loaded map — collision, behavior, and the connected +//! neighbor strips. +//! +//! **The bottom-left-tile rule.** A cell is 2x2 tiles, and its behavior comes +//! from its BOTTOM-LEFT 8x8 tile: the tile under the walker's feet. That one +//! rule decides passability, grass, doors, warp pads, water and counters, and +//! it is the reason the upstream engine's collision matches the original. It +//! is ported verbatim (upstream `src/world/Map.lua`). +//! +//! **Connections.** Outdoor maps are stitched edge to edge. Rather than build +//! one giant world, every query that lands outside the current map is +//! redirected into the neighbour declared for that side — so collision, the +//! tile renderer and the walk-off-the-edge transition all share a single +//! coordinate translation and cannot disagree about where the seam is. + +use crate::content::{conn, Content, MapDef}; +use crate::spec; + +/// Where a block coordinate resolved to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Resolved { + /// Inside the map that was asked. + Here { bx: i32, by: i32 }, + /// Inside a connected neighbour. + Neighbor { map: u16, bx: i32, by: i32 }, + /// Outside everything: the border ring. + Border, +} + +/// Translate a block coordinate that may fall outside `map` into whichever map +/// actually owns it. +/// +/// Only one axis is redirected: a coordinate off the corner (outside on both +/// axes at once) has no well-defined owner, and the original engine draws the +/// border block there too. +pub fn resolve_block(content: &Content, map: &MapDef, bx: i32, by: i32) -> Resolved { + let w = map.width as i32; + let h = map.height as i32; + let out_x = bx < 0 || bx >= w; + let out_y = by < 0 || by >= h; + + if !out_x && !out_y { + return Resolved::Here { bx, by }; + } + if out_x && out_y { + return Resolved::Border; + } + + let (side, id) = if by < 0 { + (conn::NORTH, map.conn[conn::NORTH]) + } else if by >= h { + (conn::SOUTH, map.conn[conn::SOUTH]) + } else if bx < 0 { + (conn::WEST, map.conn[conn::WEST]) + } else { + (conn::EAST, map.conn[conn::EAST]) + }; + if id < 0 { + return Resolved::Border; + } + let Some(n) = content.map_of(id as u16) else { + return Resolved::Border; + }; + let off = map.conn_off[side] as i32; + let (nbx, nby) = match side { + conn::NORTH => (bx + off, n.height as i32 + by), + conn::SOUTH => (bx + off, by - h), + conn::WEST => (n.width as i32 + bx, by + off), + _ => (bx - w, by + off), + }; + // A neighbour that does not actually cover this strip falls back to the + // border ring rather than wrapping around its own edge. + if nbx < 0 || nby < 0 || nbx >= n.width as i32 || nby >= n.height as i32 { + return Resolved::Border; + } + Resolved::Neighbor { map: n.id, bx: nbx, by: nby } +} + +/// The tile id at tile coordinates, following connections. +pub fn tile_at(content: &Content, map: &MapDef, tx: i32, ty: i32) -> u8 { + let bx = div_floor(tx, spec::BLOCK_TILES as i32); + let by = div_floor(ty, spec::BLOCK_TILES as i32); + let (owner, block) = match resolve_block(content, map, bx, by) { + Resolved::Here { bx, by } => (map, content.block_at(map, bx, by)), + Resolved::Neighbor { map: id, bx, by } => match content.map_of(id) { + Some(n) => (n, content.block_at(n, bx, by)), + None => (map, map.border_block), + }, + Resolved::Border => (map, map.border_block), + }; + let Some(ts) = content.tileset_of(owner.tileset) else { + return 0; + }; + let sub_x = rem_floor(tx, spec::BLOCK_TILES as i32) as usize; + let sub_y = rem_floor(ty, spec::BLOCK_TILES as i32) as usize; + ts.block_tile(block, sub_x, sub_y) +} + +/// The behavior of a cell, read from its bottom-left tile. +/// +/// The tile under the feet is `(cx * 2, cy * 2 + 1)`: the lower-left of the +/// cell's four tiles. +pub fn cell_behavior(content: &Content, map: &MapDef, cx: i32, cy: i32) -> u8 { + let tx = cx * 2; + let ty = cy * 2 + 1; + let bx = div_floor(tx, spec::BLOCK_TILES as i32); + let by = div_floor(ty, spec::BLOCK_TILES as i32); + let (owner, block) = match resolve_block(content, map, bx, by) { + Resolved::Here { bx, by } => (map, content.block_at(map, bx, by)), + Resolved::Neighbor { map: id, bx, by } => match content.map_of(id) { + Some(n) => (n, content.block_at(n, bx, by)), + // Fail closed: with no data we cannot prove the landing is safe, + // so the step bumps instead of stranding the player off-map. + None => return spec::cell::WALL, + }, + Resolved::Border => return spec::cell::WALL, + }; + let Some(ts) = content.tileset_of(owner.tileset) else { + return spec::cell::WALL; + }; + let sub_x = rem_floor(tx, spec::BLOCK_TILES as i32) as usize; + let sub_y = rem_floor(ty, spec::BLOCK_TILES as i32) as usize; + ts.behavior_of(ts.block_tile(block, sub_x, sub_y)) +} + +/// Can a walker stand on this cell? +pub fn passable(content: &Content, map: &MapDef, cx: i32, cy: i32, surfing: bool) -> bool { + match cell_behavior(content, map, cx, cy) { + spec::cell::FLOOR + | spec::cell::GRASS + | spec::cell::DOOR + | spec::cell::WARP + | spec::cell::LEDGE_DOWN => true, + spec::cell::WATER => surfing, + _ => false, + } +} + +/// Is this a tall-grass cell (the encounter roll's trigger)? +pub fn is_grass(content: &Content, map: &MapDef, cx: i32, cy: i32) -> bool { + cell_behavior(content, map, cx, cy) == spec::cell::GRASS +} + +/// Would stepping `dir` from (cx, cy) hop a ledge? +/// +/// Ledges are one-way: only a downward step onto a ledge cell jumps, and the +/// landing two cells south must itself be standable. +pub fn ledge_hop(content: &Content, map: &MapDef, cx: i32, cy: i32, dir: u8) -> bool { + if dir != spec::dir::DOWN { + return false; + } + if cell_behavior(content, map, cx, cy + 1) != spec::cell::LEDGE_DOWN { + return false; + } + passable(content, map, cx, cy + 2, false) +} + +/// The cell one step in `dir`. +pub fn step(cx: i32, cy: i32, dir: u8) -> (i32, i32) { + match dir { + spec::dir::UP => (cx, cy - 1), + spec::dir::DOWN => (cx, cy + 1), + spec::dir::LEFT => (cx - 1, cy), + _ => (cx + 1, cy), + } +} + +/// Translate a cell that has fallen outside `map` into the connected +/// neighbour that owns it. +/// +/// This is the *rebase* half of map connections. Because [`cell_behavior`] +/// already resolves through connections, a walker simply steps off the edge +/// like any other step — no teleport, no special-cased input. Once the step +/// lands, the overworld calls this to work out which map it is standing on +/// now. One translation, shared by rendering and movement, so the seam cannot +/// disagree with itself. +/// +/// Returns `None` when the cell is still inside `map`, or when no neighbour +/// covers it. +pub fn rebase_cell(content: &Content, map: &MapDef, cx: i32, cy: i32) -> Option<(u16, i32, i32)> { + let w = map.width_cells(); + let h = map.height_cells(); + if cx >= 0 && cy >= 0 && cx < w && cy < h { + return None; + } + // Corners belong to nobody (the border ring), same rule as resolve_block. + let out_x = cx < 0 || cx >= w; + let out_y = cy < 0 || cy >= h; + if out_x && out_y { + return None; + } + let side = if cy < 0 { + conn::NORTH + } else if cy >= h { + conn::SOUTH + } else if cx < 0 { + conn::WEST + } else { + conn::EAST + }; + let id = map.conn[side]; + if id < 0 { + return None; + } + let n = content.map_of(id as u16)?; + // Offsets are declared in blocks; the walk grid is cells. + let off = map.conn_off[side] as i32 * spec::BLOCK_CELLS; + let (dx, dy) = match side { + conn::NORTH => (cx + off, n.height_cells() + cy), + conn::SOUTH => (cx + off, cy - h), + conn::WEST => (n.width_cells() + cx, cy + off), + _ => (cx - w, cy + off), + }; + if dx < 0 || dy < 0 || dx >= n.width_cells() || dy >= n.height_cells() { + return None; + } + Some((n.id, dx, dy)) +} + +/// Floor division that stays correct for negatives (`-1 / 4` must be `-1`, +/// not `0`) — every off-map tile query depends on it. +#[inline] +pub fn div_floor(a: i32, b: i32) -> i32 { + let q = a / b; + if (a % b != 0) && ((a < 0) != (b < 0)) { + q - 1 + } else { + q + } +} + +/// Euclidean remainder matching [`div_floor`], always in `0..b`. +#[inline] +pub fn rem_floor(a: i32, b: i32) -> i32 { + let r = a % b; + if r < 0 { + r + b + } else { + r + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::Tileset; + use alloc::vec; + use alloc::vec::Vec; + + /// A tileset where tile id N has behavior N (so tests can name behaviors + /// directly) and block id B is filled entirely with tile id B. + fn test_content() -> Content { + let mut c = Content::new(); + let mut behavior = [spec::cell::WALL; spec::TILE_BEHAVIOR_BYTES]; + for (i, b) in behavior.iter_mut().enumerate() { + *b = i as u8; + } + let mut blocks = Vec::new(); + for b in 0..16u8 { + blocks.push([b; spec::TILESET_BLOCK_SIZE]); + } + c.tilesets.push(Tileset { blocks, behavior }); + c + } + + fn map(id: u16, w: u8, h: u8, fill: u8) -> MapDef { + MapDef { + id, + width: w, + height: h, + tileset: 0, + border_block: spec::cell::WALL, + blocks: vec![fill; w as usize * h as usize], + conn: [-1; 4], + ..Default::default() + } + } + + #[test] + fn floor_division_handles_negatives() { + assert_eq!(div_floor(-1, 4), -1); + assert_eq!(div_floor(-4, 4), -1); + assert_eq!(div_floor(-5, 4), -2); + assert_eq!(div_floor(3, 4), 0); + assert_eq!(rem_floor(-1, 4), 3); + assert_eq!(rem_floor(-4, 4), 0); + assert_eq!(rem_floor(5, 4), 1); + } + + #[test] + fn behavior_reads_the_bottom_left_tile() { + let mut c = test_content(); + // Block 0 is all FLOOR except the bottom-left tile of cell (0,0), + // which is tile index (ty=1, tx=0) = row 1, col 0 of the block. + let mut b = [spec::cell::FLOOR; spec::TILESET_BLOCK_SIZE]; + b[1 * spec::BLOCK_TILES] = spec::cell::WALL; + c.tilesets[0].blocks[0] = b; + let m = map(1, 1, 1, 0); + // Cell (0,0) reads the doctored tile -> impassable. + assert_eq!(cell_behavior(&c, &m, 0, 0), spec::cell::WALL); + assert!(!passable(&c, &m, 0, 0, false)); + // Cell (1,0) reads (tx=2, ty=1), untouched -> passable. + assert_eq!(cell_behavior(&c, &m, 1, 0), spec::cell::FLOOR); + assert!(passable(&c, &m, 1, 0, false)); + } + + #[test] + fn off_map_is_wall_without_a_connection() { + let c = test_content(); + let m = map(1, 2, 2, spec::cell::FLOOR); + assert!(passable(&c, &m, 0, 0, false)); + assert_eq!(cell_behavior(&c, &m, -1, 0), spec::cell::WALL); + assert_eq!(cell_behavior(&c, &m, 0, -1), spec::cell::WALL); + assert_eq!(cell_behavior(&c, &m, 4, 0), spec::cell::WALL); + } + + #[test] + fn water_is_passable_only_while_surfing() { + let c = test_content(); + let m = map(1, 1, 1, spec::cell::WATER); + assert!(!passable(&c, &m, 0, 0, false)); + assert!(passable(&c, &m, 0, 0, true)); + } + + #[test] + fn connections_redirect_queries_into_the_neighbour() { + let mut c = test_content(); + let mut south = map(2, 2, 2, spec::cell::GRASS); + south.conn[conn::NORTH] = 1; + let mut north = map(1, 2, 2, spec::cell::FLOOR); + north.conn[conn::SOUTH] = 2; + c.maps.insert(1, north.clone()); + c.maps.insert(2, south); + + // One block below the north map is the south map's top row: GRASS. + assert_eq!(cell_behavior(&c, &north, 0, north.height_cells()), spec::cell::GRASS); + // Its own cells stay FLOOR. + assert_eq!(cell_behavior(&c, &north, 0, 0), spec::cell::FLOOR); + // Corners have no owner: border. + assert_eq!( + resolve_block(&c, &north, -1, north.height as i32), + Resolved::Border + ); + } + + #[test] + fn rebasing_translates_an_off_map_cell_into_the_neighbour() { + let mut c = test_content(); + let mut north = map(1, 2, 2, spec::cell::FLOOR); + north.conn[conn::SOUTH] = 2; + let south = map(2, 2, 2, spec::cell::FLOOR); + c.maps.insert(1, north.clone()); + c.maps.insert(2, south); + + // One cell below the bottom row belongs to the southern neighbour. + let below = north.height_cells(); + assert_eq!(rebase_cell(&c, &north, 1, below), Some((2, 1, 0))); + // A cell still inside is not a rebase. + assert_eq!(rebase_cell(&c, &north, 1, 0), None); + // No connection on that side: nothing to rebase onto. + assert_eq!(rebase_cell(&c, &north, 1, -1), None); + // Corners belong to nobody. + assert_eq!(rebase_cell(&c, &north, -1, below), None); + } + + #[test] + fn connection_offsets_shift_the_seam() { + let mut c = test_content(); + let mut north = map(1, 2, 2, spec::cell::FLOOR); + north.conn[conn::SOUTH] = 2; + north.conn_off[conn::SOUTH] = 1; // neighbour sits one block to the right + let south = map(2, 4, 2, spec::cell::FLOOR); + c.maps.insert(1, north.clone()); + c.maps.insert(2, south); + let below = north.height_cells(); + // cell x=0 maps to neighbour x = 0 + 1 block = 2 cells + assert_eq!(rebase_cell(&c, &north, 0, below), Some((2, 2, 0))); + } + + #[test] + fn rebasing_round_trips_across_a_seam() { + // Walking south then north must land back on the cell we left, or the + // seam drifts every crossing. + let mut c = test_content(); + let mut north = map(1, 3, 2, spec::cell::FLOOR); + north.conn[conn::SOUTH] = 2; + north.conn_off[conn::SOUTH] = 1; + let mut south = map(2, 4, 2, spec::cell::FLOOR); + south.conn[conn::NORTH] = 1; + south.conn_off[conn::NORTH] = -1; + c.maps.insert(1, north.clone()); + c.maps.insert(2, south.clone()); + + let start = (2, north.height_cells() - 1); + let (nmap, nx, ny) = rebase_cell(&c, &north, start.0, start.1 + 1).expect("south"); + assert_eq!(nmap, 2); + let back = rebase_cell(&c, &south, nx, ny - 1).expect("north"); + assert_eq!(back, (1, start.0, start.1)); + } + + #[test] + fn ledges_hop_only_downward_onto_clear_ground() { + let mut c = test_content(); + let mut m = map(1, 2, 2, spec::cell::FLOOR); + // Put a ledge in cell (0,1) by giving its block LEDGE_DOWN behavior. + m.blocks = vec![spec::cell::FLOOR, spec::cell::FLOOR, spec::cell::FLOOR, spec::cell::FLOOR]; + // cell (0,1) lives in block (0,0) too (2 cells per block), so doctor + // the tile the rule actually reads. + let mut b = [spec::cell::FLOOR; spec::TILESET_BLOCK_SIZE]; + b[3 * spec::BLOCK_TILES] = spec::cell::LEDGE_DOWN; // tx=0, ty=3 -> cell (0,1) + c.tilesets[0].blocks[spec::cell::FLOOR as usize] = b; + assert!(ledge_hop(&c, &m, 0, 0, spec::dir::DOWN)); + assert!(!ledge_hop(&c, &m, 0, 0, spec::dir::UP)); + assert!(!ledge_hop(&c, &m, 0, 0, spec::dir::LEFT)); + // Landing cell (0,2) is off this 2x2-block map's 4-cell height? no: h=4 + // cells, so (0,2) exists and is floor. + assert!(passable(&c, &m, 0, 2, false)); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/world/mod.rs b/engine/pocketmon/crates/pocketmon-core/src/world/mod.rs new file mode 100644 index 00000000..9dbd5cad --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/world/mod.rs @@ -0,0 +1,8 @@ +//! The world layer: maps, actors and the overworld state machine. + +pub mod actor; +pub mod map; +pub mod overworld; + +pub use actor::Actor; +pub use overworld::{Fade, World, WorldGate}; diff --git a/engine/pocketmon/crates/pocketmon-core/src/world/overworld.rs b/engine/pocketmon/crates/pocketmon-core/src/world/overworld.rs new file mode 100644 index 00000000..34270931 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/world/overworld.rs @@ -0,0 +1,870 @@ +//! The overworld: input, movement, interaction, warps, connections and the +//! encounter roll — one tick at a time. +//! +//! Ported from upstream `src/world/OverworldController.lua`, which is the +//! single largest module in that project (3.9 kLOC) because it is where every +//! world rule meets every other one. The ordering below is load-bearing and +//! matches the original engine's frame: +//! +//! 1. resolve scripted movement (scripts own actors while they run) +//! 2. advance every actor's animation; note who *landed* this frame +//! 3. on the player landing: warp pad? map edge? grass encounter? +//! 4. only if nothing fired and the player is idle: read input +//! +//! Reading input last is what makes a warp feel instant — the player never +//! gets a frame of control on the tile they are about to leave. + +use crate::content::Content; +use crate::event::{EventQueue, MonEvent}; +use crate::rng::Rng; +use crate::spec; + +use super::actor::Actor; +use super::map; +#[cfg(test)] +use super::actor::WALK_FRAMES; + +/// Frames a full screen fade takes in each direction. +pub const FADE_FRAMES: u16 = 12; +/// Frames between wander-AI rolls for an idle NPC. +const WANDER_PERIOD: u16 = 48; +/// Chance in 256 that an idle wandering NPC takes a step when it rolls. +const WANDER_CHANCE: u32 = 96; + +/// A queued map transition. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PendingWarp { + pub map: u16, + pub cx: i32, + pub cy: i32, + pub dir: u8, + /// Fade through black (door/stairs) vs. seamless (map connection). + pub fade: bool, +} + +/// Screen fade state driving warp transitions. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Fade { + /// 0 = clear, FADE_FRAMES = fully black. + pub level: u16, + pub closing: bool, + pub active: bool, +} + +impl Fade { + pub fn opaque(&self) -> bool { + self.level >= FADE_FRAMES + } + + /// 0..=255 alpha for the black overlay. + pub fn alpha(&self) -> u32 { + (self.level as u32 * 255 / FADE_FRAMES as u32).min(255) + } +} + +/// What the world is doing, which decides whether it reads input. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WorldGate { + /// Normal play: input is live. + Free, + /// A textbox, menu, battle or script owns the screen; actors still animate + /// so scripted walks can finish, but the player has no control. + Held, +} + +/// The overworld state. +#[derive(Clone, Debug)] +pub struct World { + pub map_id: u16, + pub actors: [Actor; spec::ACTORS_MAX], + /// Camera top-left in world pixels. + pub cam_x: i32, + pub cam_y: i32, + pub surfing: bool, + /// The last outdoor map entered — where "escape" and the doorway-exit rule + /// send the player back to. + pub last_outdoor: u16, + pub steps: u32, + pub fade: Fade, + pub pending: Option, + /// Set for one frame when a step bumped a wall (the host plays a thud). + pub bumped: bool, + /// A block a script asked to replace, applied by the owner of `Content`. + pub pending_block: Option<(i32, i32, u8)>, +} + +impl Default for World { + fn default() -> Self { + World { + map_id: 0, + actors: core::array::from_fn(|_| Actor::default()), + cam_x: 0, + cam_y: 0, + surfing: false, + last_outdoor: 0, + steps: 0, + fade: Fade::default(), + pending: None, + bumped: false, + pending_block: None, + } + } +} + +impl World { + pub fn new() -> Self { + World::default() + } + + pub fn player(&self) -> &Actor { + &self.actors[0] + } + + pub fn player_mut(&mut self) -> &mut Actor { + &mut self.actors[0] + } + + /// Place the player on a map and spawn its actors. Hard placement: no + /// fade, no events — the new-game and save-load entry point. + pub fn enter_map(&mut self, content: &Content, flags: &[u8], id: u16, cx: i32, cy: i32, dir: u8) { + self.map_id = id; + let player = Actor { + active: true, + visible: true, + cx, + cy, + dir, + ..Default::default() + }; + self.actors = core::array::from_fn(|_| Actor::default()); + self.actors[0] = player; + + if let Some(m) = content.map_of(id) { + if !m.indoor() { + self.last_outdoor = id; + } + for (i, def) in m.actors.iter().take(spec::ACTORS_MAX - 1).enumerate() { + let gated = def.flag_gate != 0xffff && flag_set(flags, def.flag_gate); + self.actors[i + 1] = Actor { + active: true, + visible: !gated, + cx: def.x as i32, + cy: def.y as i32, + dir: def.dir, + sprite: def.sprite, + behavior: def.behavior, + text_key: def.text_key, + trainer_id: def.trainer_id, + flag_gate: def.flag_gate, + wander_cd: WANDER_PERIOD, + ..Default::default() + }; + } + } + self.snap_camera(); + } + + /// Re-anchor onto a connected neighbour, preserving the player's facing + /// and walk state. Unlike [`Self::enter_map`] this is not a placement: the + /// player already walked here, the map underneath them just changed. + fn rebase_to(&mut self, content: &Content, flags: &[u8], id: u16, cx: i32, cy: i32) { + let keep = self.actors[0].clone(); + self.enter_map(content, flags, id, cx, cy, keep.dir); + self.actors[0] = Actor { cx, cy, ..keep }; + } + + /// Centre the camera on the player immediately (no easing — the original + /// camera is rigid, and a lagging camera would desync goldens). + pub fn snap_camera(&mut self) { + let (px, py) = self.player().pixel_pos(); + self.cam_x = px + spec::CELL_PX / 2 - spec::VIEW_W / 2; + self.cam_y = py + spec::CELL_PX / 2 - spec::VIEW_H / 2; + } + + /// Queue a warp with a fade. + pub fn warp_to(&mut self, map: u16, cx: i32, cy: i32, dir: u8, fade: bool) { + self.pending = Some(PendingWarp { map, cx, cy, dir, fade }); + if fade { + self.fade.active = true; + self.fade.closing = true; + } + } + + /// Is any actor other than `skip` standing on (or claiming) this cell? + pub fn occupied_by_actor(&self, cx: i32, cy: i32, skip: usize) -> bool { + self.actors.iter().enumerate().any(|(i, a)| { + i != skip && a.active && a.visible && { + let (ox, oy) = a.occupied(); + ox == cx && oy == cy + } + }) + } + + /// The actor standing in front of the player, if any. + pub fn facing_actor(&self) -> Option { + let p = self.player(); + let (fx, fy) = map::step(p.cx, p.cy, p.dir); + self.actors.iter().enumerate().skip(1).find_map(|(i, a)| { + (a.active && a.visible && a.cx == fx && a.cy == fy).then_some(i) + }) + } + + /// Advance one frame. + /// + /// `gate` decides whether player input is read; actors animate either way. + pub fn update( + &mut self, + content: &Content, + flags: &[u8], + rng: &mut Rng, + buttons: u32, + pressed: u32, + gate: WorldGate, + events: &mut EventQueue, + ) { + self.bumped = false; + + if self.step_fade(content, flags, events) { + return; + } + + self.drive_scripted_actors(content); + if gate == WorldGate::Free { + self.wander(content, rng); + } + + // Advance everyone; the player's landing is the one with consequences. + let mut player_landed = false; + for i in 0..self.actors.len() { + if !self.actors[i].active { + continue; + } + let landed = self.actors[i].advance(); + if landed && i == 0 { + player_landed = true; + } + } + + if player_landed && self.on_player_landed(content, flags, rng, events) { + self.snap_camera(); + return; + } + + if gate == WorldGate::Free && self.player().idle() && self.pending.is_none() { + self.read_input(content, buttons, pressed, events); + } + + self.snap_camera(); + } + + /// Drive the fade/warp transition. Returns true when the frame belongs + /// entirely to the transition. + fn step_fade(&mut self, content: &Content, flags: &[u8], events: &mut EventQueue) -> bool { + if !self.fade.active { + // A fadeless warp (map connection) applies immediately. + if let Some(w) = self.pending.take() { + self.apply_warp(content, flags, w, events); + return true; + } + return false; + } + + if self.fade.closing { + self.fade.level += 1; + if self.fade.level >= FADE_FRAMES { + self.fade.level = FADE_FRAMES; + if let Some(w) = self.pending.take() { + self.apply_warp(content, flags, w, events); + } + self.fade.closing = false; + } + } else { + self.fade.level = self.fade.level.saturating_sub(1); + if self.fade.level == 0 { + self.fade.active = false; + } + } + true + } + + fn apply_warp(&mut self, content: &Content, flags: &[u8], w: PendingWarp, events: &mut EventQueue) { + self.enter_map(content, flags, w.map, w.cx, w.cy, w.dir); + events.push(MonEvent { + kind: spec::event::WARPED, + a: w.map, + b: w.cx, + c: w.cy, + d: w.dir as i32, + }); + } + + /// Feed queued scripted steps to idle actors. + fn drive_scripted_actors(&mut self, content: &Content) { + for i in 0..self.actors.len() { + if !self.actors[i].active || !self.actors[i].idle() || self.actors[i].queue_len == 0 { + continue; + } + let Some(dir) = self.actors[i].next_queued() else { + continue; + }; + let (cx, cy) = (self.actors[i].cx, self.actors[i].cy); + let (nx, ny) = map::step(cx, cy, dir); + // Scripted walks ignore NPC crowding (a cutscene must not deadlock + // on a wanderer) but still respect solid geometry. + let walkable = content + .map_of(self.map_id) + .map(|m| map::passable(content, m, nx, ny, self.surfing)) + .unwrap_or(false); + if walkable { + self.actors[i].begin_step(dir, false); + } else { + self.actors[i].dir = dir; + } + } + } + + /// Idle NPCs with a wander behavior take the occasional step. + fn wander(&mut self, content: &Content, rng: &mut Rng) { + let Some(m) = content.map_of(self.map_id) else { + return; + }; + for i in 1..self.actors.len() { + let a = &self.actors[i]; + if !a.active || !a.visible || !a.idle() || a.scripted { + continue; + } + if a.behavior == spec::behavior::STILL { + continue; + } + if self.actors[i].wander_cd > 0 { + self.actors[i].wander_cd -= 1; + continue; + } + self.actors[i].wander_cd = WANDER_PERIOD; + if !rng.chance(WANDER_CHANCE, 256) { + continue; + } + let behavior = self.actors[i].behavior; + let dir = match behavior { + spec::behavior::PACE_H => { + if rng.chance(1, 2) { + spec::dir::LEFT + } else { + spec::dir::RIGHT + } + } + spec::behavior::PACE_V => { + if rng.chance(1, 2) { + spec::dir::UP + } else { + spec::dir::DOWN + } + } + spec::behavior::SPIN => { + // Spinners only turn; they never leave their cell. + self.actors[i].dir = rng.range(0, 3) as u8; + continue; + } + _ => rng.range(0, 3) as u8, + }; + let (cx, cy) = (self.actors[i].cx, self.actors[i].cy); + let (nx, ny) = map::step(cx, cy, dir); + self.actors[i].dir = dir; + if map::passable(content, m, nx, ny, false) && !self.occupied_by_actor(nx, ny, i) { + self.actors[i].begin_step(dir, false); + } + } + } + + /// Consequences of the player arriving on a new cell. Returns true when + /// the frame is consumed (a warp or an encounter fired). + fn on_player_landed( + &mut self, + content: &Content, + flags: &[u8], + rng: &mut Rng, + events: &mut EventQueue, + ) -> bool { + self.steps = self.steps.wrapping_add(1); + + // A step that ended outside the current map means the player walked + // across a seam: adopt the neighbour before anything else reads the + // map, so warps and encounters resolve against where they actually are. + if let Some(m) = content.map_of(self.map_id) { + let (cx, cy) = (self.player().cx, self.player().cy); + if let Some((nmap, nx, ny)) = map::rebase_cell(content, m, cx, cy) { + self.rebase_to(content, flags, nmap, nx, ny); + events.push(MonEvent { + kind: spec::event::WARPED, + a: nmap, + b: nx, + c: ny, + d: self.player().dir as i32, + }); + } + } + + let Some(m) = content.map_of(self.map_id) else { + return false; + }; + let (cx, cy) = (self.player().cx, self.player().cy); + + // Warp pads and doors fire on arrival. + if let Some((_, w)) = m.warp_at(cx, cy) { + let dest = *w; + if let Some(dm) = content.map_of(dest.dest_map) { + let (dx, dy, ddir) = match dm.warps.get(dest.dest_warp as usize) { + Some(dw) => (dw.x as i32, dw.y as i32, dest.dir), + // A warp pointing at a missing index drops the player on + // the destination's first warp rather than at (0,0). + None => match dm.warps.first() { + Some(dw) => (dw.x as i32, dw.y as i32, dest.dir), + None => (0, 0, dest.dir), + }, + }; + self.warp_to(dest.dest_map, dx, dy, ddir, true); + return true; + } + } + + // Grass encounters. + if map::is_grass(content, m, cx, cy) && m.encounter_rate > 0 && !m.slots.is_empty() { + if rng.byte() < m.encounter_rate as u32 { + let pick = rng.byte() as u16; + let idx = spec::ENCOUNTER_BUCKETS + .iter() + .position(|&t| pick < t) + .unwrap_or(spec::ENCOUNTER_BUCKETS.len() - 1); + if let Some(slot) = m.slots.get(idx).or_else(|| m.slots.last()) { + events.push(MonEvent { + kind: spec::event::ENCOUNTER, + a: slot.species, + b: slot.level as i32, + c: 0, + d: 0, + }); + return true; + } + } + } + false + } + + /// Read the pad and act on it. + fn read_input(&mut self, content: &Content, buttons: u32, pressed: u32, events: &mut EventQueue) { + if pressed & spec::btn::START != 0 { + events.push(MonEvent { kind: spec::event::MENU_REQUEST, a: 0, b: 0, c: 0, d: 0 }); + return; + } + if pressed & spec::btn::A != 0 { + self.interact(content, events); + return; + } + + let dir = if buttons & spec::btn::UP != 0 { + Some(spec::dir::UP) + } else if buttons & spec::btn::DOWN != 0 { + Some(spec::dir::DOWN) + } else if buttons & spec::btn::LEFT != 0 { + Some(spec::dir::LEFT) + } else if buttons & spec::btn::RIGHT != 0 { + Some(spec::dir::RIGHT) + } else { + None + }; + let Some(dir) = dir else { return }; + + // Facing a new way costs a beat before walking — the original's + // "turn in place" so a tap re-aims without moving. + if self.player().dir != dir { + self.player_mut().begin_turn(dir); + return; + } + + let Some(m) = content.map_of(self.map_id) else { + return; + }; + let (cx, cy) = (self.player().cx, self.player().cy); + + if map::ledge_hop(content, m, cx, cy, dir) { + self.player_mut().begin_step(dir, true); + return; + } + + // Stepping off the edge needs no special case: `passable` resolves + // through connections, so the walk animation runs normally and the + // landing rebases onto the neighbour. + let (nx, ny) = map::step(cx, cy, dir); + if map::passable(content, m, nx, ny, self.surfing) && !self.occupied_by_actor(nx, ny, 0) { + self.player_mut().begin_step(dir, false); + } else { + self.bumped = true; + } + } + + /// The A button: talk to whoever is in front, or read the sign there. + fn interact(&mut self, content: &Content, events: &mut EventQueue) { + if let Some(i) = self.facing_actor() { + let dir = self.player().dir; + let key = self.actors[i].text_key; + let trainer = self.actors[i].trainer_id; + // NPCs turn to face you before speaking. + self.actors[i].dir = super::actor::opposite(dir); + events.push(MonEvent { + kind: spec::event::TALK, + a: i as u16, + b: key as i32, + c: trainer as i32, + d: 0, + }); + return; + } + let Some(m) = content.map_of(self.map_id) else { + return; + }; + let p = self.player(); + let (fx, fy) = map::step(p.cx, p.cy, p.dir); + if let Some(s) = m.sign_at(fx, fy) { + events.push(MonEvent { + kind: spec::event::SIGN, + a: 0, + b: s.text_key as i32, + c: 0, + d: 0, + }); + } + } +} + +/// Read one bit out of the packed flag array. +pub fn flag_set(flags: &[u8], id: u16) -> bool { + let byte = id as usize / 8; + match flags.get(byte) { + Some(b) => b & (1 << (id % 8)) != 0, + None => false, + } +} + +/// Write one bit into the packed flag array. +pub fn set_flag(flags: &mut [u8], id: u16, value: bool) { + let byte = id as usize / 8; + if let Some(b) = flags.get_mut(byte) { + let mask = 1 << (id % 8); + if value { + *b |= mask; + } else { + *b &= !mask; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content::{ActorDef, MapDef, Sign, Tileset, Warp}; + use alloc::vec; + use alloc::vec::Vec; + + fn content_with(maps: Vec) -> Content { + let mut c = Content::new(); + let mut behavior = [spec::cell::WALL; spec::TILE_BEHAVIOR_BYTES]; + for (i, b) in behavior.iter_mut().enumerate() { + *b = i as u8; + } + let mut blocks = Vec::new(); + for b in 0..16u8 { + blocks.push([b; spec::TILESET_BLOCK_SIZE]); + } + c.tilesets.push(Tileset { blocks, behavior }); + for m in maps { + c.maps.insert(m.id, m); + } + c + } + + fn open_map(id: u16, w: u8, h: u8) -> MapDef { + MapDef { + id, + width: w, + height: h, + tileset: 0, + border_block: spec::cell::WALL, + blocks: vec![spec::cell::FLOOR; w as usize * h as usize], + conn: [-1; 4], + ..Default::default() + } + } + + fn flags() -> Vec { + vec![0u8; spec::FLAG_COUNT / 8] + } + + /// Run `n` frames with a held button mask. + fn run(w: &mut World, c: &Content, n: usize, buttons: u32, first_press: u32) { + let mut rng = Rng::new(1); + let mut ev = EventQueue::new(); + let f = flags(); + for i in 0..n { + let pressed = if i == 0 { first_press } else { 0 }; + w.update(c, &f, &mut rng, buttons, pressed, WorldGate::Free, &mut ev); + } + } + + /// Frames to complete one step from an idle, already-facing start: input + /// is read at the END of a frame, so the step begins on frame 1 and the + /// landing happens on frame WALK_FRAMES + 1. + const STEP_FRAMES: usize = WALK_FRAMES as usize + 1; + + #[test] + fn walking_takes_a_turn_then_a_step() { + let c = content_with(vec![open_map(1, 4, 4)]); + let mut w = World::new(); + w.enter_map(&c, &flags(), 1, 2, 2, spec::dir::DOWN); + + // Facing DOWN already; holding RIGHT first turns. + run(&mut w, &c, 1, spec::btn::RIGHT, 0); + assert_eq!(w.player().dir, spec::dir::RIGHT); + assert_eq!((w.player().cx, w.player().cy), (2, 2), "the turn does not move"); + + run(&mut w, &c, super::super::actor::TURN_FRAMES as usize, spec::btn::RIGHT, 0); + run(&mut w, &c, STEP_FRAMES, spec::btn::RIGHT, 0); + assert_eq!((w.player().cx, w.player().cy), (3, 2)); + } + + #[test] + fn walls_bump_instead_of_moving() { + let mut m = open_map(1, 4, 4); + m.blocks[0] = spec::cell::WALL; + let c = content_with(vec![m]); + let mut w = World::new(); + // Stand at (2,0) facing UP: north is off-map -> wall. + w.enter_map(&c, &flags(), 1, 2, 0, spec::dir::UP); + run(&mut w, &c, 4, spec::btn::UP, 0); + assert_eq!((w.player().cx, w.player().cy), (2, 0)); + assert!(w.bumped); + } + + #[test] + fn a_warp_pad_fades_and_moves_the_player() { + let mut a = open_map(1, 4, 4); + a.warps.push(Warp { x: 2, y: 3, dest_map: 2, dest_warp: 0, dir: spec::dir::DOWN }); + let mut b = open_map(2, 4, 4); + b.warps.push(Warp { x: 1, y: 1, dest_map: 1, dest_warp: 0, dir: spec::dir::UP }); + let c = content_with(vec![a, b]); + + let mut w = World::new(); + w.enter_map(&c, &flags(), 1, 2, 2, spec::dir::DOWN); + // Step onto the pad. + run(&mut w, &c, STEP_FRAMES, spec::btn::DOWN, 0); + assert_eq!(w.map_id, 1, "the warp starts on the landing frame"); + assert!(w.fade.active && w.fade.closing); + // Fade out, swap, fade in. + run(&mut w, &c, (FADE_FRAMES * 2 + 2) as usize, 0, 0); + assert_eq!(w.map_id, 2); + assert_eq!((w.player().cx, w.player().cy), (1, 1)); + assert!(!w.fade.active); + } + + #[test] + fn grass_rolls_on_landing_not_on_standing_still() { + let mut grass = open_map(1, 4, 4); + grass.blocks = vec![spec::cell::GRASS; 16]; + grass.encounter_rate = 255; // always + grass.slots = vec![crate::content::EncounterSlot { species: 7, level: 3 }; 10]; + let c = content_with(vec![grass]); + + let mut w = World::new(); + let mut rng = Rng::new(3); + let mut ev = EventQueue::new(); + let f = flags(); + w.enter_map(&c, &f, 1, 1, 1, spec::dir::DOWN); + + // Standing on grass never rolls — only stepping onto a cell does. + for _ in 0..60 { + w.update(&c, &f, &mut rng, 0, 0, WorldGate::Free, &mut ev); + } + assert!(ev.drain().iter().all(|e| e.kind != spec::event::ENCOUNTER)); + + // One step lands and, at rate 255, always rolls an encounter. + let mut ev = EventQueue::new(); + for _ in 0..STEP_FRAMES { + w.update(&c, &f, &mut rng, spec::btn::DOWN, 0, WorldGate::Free, &mut ev); + } + let encounters: Vec<_> = + ev.drain().iter().filter(|e| e.kind == spec::event::ENCOUNTER).copied().collect(); + assert_eq!(encounters.len(), 1); + assert_eq!(encounters[0].a, 7); + assert_eq!(encounters[0].b, 3); + } + + #[test] + fn a_zero_rate_map_never_rolls() { + let mut grass = open_map(1, 4, 4); + grass.blocks = vec![spec::cell::GRASS; 16]; + grass.encounter_rate = 0; + grass.slots = vec![crate::content::EncounterSlot { species: 7, level: 3 }; 10]; + let c = content_with(vec![grass]); + let mut w = World::new(); + let mut rng = Rng::new(4); + let mut ev = EventQueue::new(); + let f = flags(); + w.enter_map(&c, &f, 1, 1, 0, spec::dir::DOWN); + for _ in 0..(STEP_FRAMES * 3) { + w.update(&c, &f, &mut rng, spec::btn::DOWN, 0, WorldGate::Free, &mut ev); + } + assert!(ev.peek().iter().all(|e| e.kind != spec::event::ENCOUNTER)); + } + + #[test] + fn walking_across_a_seam_rebases_onto_the_neighbour() { + let mut north = open_map(1, 2, 2); + north.conn[crate::content::conn::SOUTH] = 2; + let mut south = open_map(2, 2, 2); + south.conn[crate::content::conn::NORTH] = 1; + let c = content_with(vec![north, south]); + + let mut w = World::new(); + let f = flags(); + // Bottom row of the north map (2 blocks tall = 4 cells). + w.enter_map(&c, &f, 1, 1, 3, spec::dir::DOWN); + let mut rng = Rng::new(5); + let mut ev = EventQueue::new(); + for _ in 0..STEP_FRAMES { + w.update(&c, &f, &mut rng, spec::btn::DOWN, 0, WorldGate::Free, &mut ev); + } + assert_eq!(w.map_id, 2, "the player is standing on the southern map"); + assert_eq!((w.player().cx, w.player().cy), (1, 0)); + assert_eq!(w.player().dir, spec::dir::DOWN, "facing survives the seam"); + assert!(ev.peek().iter().any(|e| e.kind == spec::event::WARPED)); + + // Release, letting the step a held button already started finish. + run(&mut w, &c, STEP_FRAMES, 0, 0); + let (rx, ry) = (w.player().cx, w.player().cy); + assert_eq!(w.map_id, 2); + + // Walking back north crosses the same seam the other way. The exact + // cell-for-cell round trip is pinned in map.rs; here we only need the + // seam to hand ownership back. + let mut ev = EventQueue::new(); + for _ in 0..(super::super::actor::TURN_FRAMES as usize + STEP_FRAMES * (ry as usize + 2)) { + w.update(&c, &f, &mut rng, spec::btn::UP, 0, WorldGate::Free, &mut ev); + } + assert_eq!(w.map_id, 1, "back on the northern map"); + assert_eq!(w.player().cx, rx, "the crossing does not drift sideways"); + } + + #[test] + fn a_seam_with_no_neighbour_bumps() { + let c = content_with(vec![open_map(1, 2, 2)]); + let mut w = World::new(); + let f = flags(); + w.enter_map(&c, &f, 1, 1, 3, spec::dir::DOWN); + run(&mut w, &c, STEP_FRAMES, spec::btn::DOWN, 0); + assert_eq!((w.player().cx, w.player().cy), (1, 3)); + assert_eq!(w.map_id, 1); + } + + #[test] + fn talking_faces_the_npc_and_reports_it() { + let mut m = open_map(1, 4, 4); + m.actors.push(ActorDef { + x: 2, + y: 1, + dir: spec::dir::DOWN, + text_key: 42, + trainer_id: -1, + flag_gate: 0xffff, + ..Default::default() + }); + let c = content_with(vec![m]); + let mut w = World::new(); + w.enter_map(&c, &flags(), 1, 2, 2, spec::dir::UP); + + let mut rng = Rng::new(1); + let mut ev = EventQueue::new(); + w.update(&c, &flags(), &mut rng, 0, spec::btn::A, WorldGate::Free, &mut ev); + let talks: Vec<_> = ev.drain().iter().filter(|e| e.kind == spec::event::TALK).copied().collect(); + assert_eq!(talks.len(), 1); + assert_eq!(talks[0].b, 42); + assert_eq!(w.actors[1].dir, spec::dir::DOWN, "the NPC turns to face the player"); + } + + #[test] + fn signs_report_their_text_when_nothing_blocks_them() { + let mut m = open_map(1, 4, 4); + m.signs.push(Sign { x: 2, y: 1, text_key: 9 }); + let c = content_with(vec![m]); + let mut w = World::new(); + w.enter_map(&c, &flags(), 1, 2, 2, spec::dir::UP); + let mut rng = Rng::new(1); + let mut ev = EventQueue::new(); + w.update(&c, &flags(), &mut rng, 0, spec::btn::A, WorldGate::Free, &mut ev); + let signs: Vec<_> = ev.drain().iter().filter(|e| e.kind == spec::event::SIGN).copied().collect(); + assert_eq!(signs.len(), 1); + assert_eq!(signs[0].b, 9); + } + + #[test] + fn a_held_gate_freezes_player_input() { + let c = content_with(vec![open_map(1, 4, 4)]); + let mut w = World::new(); + w.enter_map(&c, &flags(), 1, 2, 2, spec::dir::DOWN); + let mut rng = Rng::new(1); + let mut ev = EventQueue::new(); + let f = flags(); + for _ in 0..40 { + w.update(&c, &f, &mut rng, spec::btn::DOWN, 0, WorldGate::Held, &mut ev); + } + assert_eq!((w.player().cx, w.player().cy), (2, 2)); + } + + #[test] + fn gated_actors_spawn_hidden() { + let mut m = open_map(1, 4, 4); + m.actors.push(ActorDef { x: 1, y: 1, flag_gate: 3, ..Default::default() }); + let c = content_with(vec![m]); + let mut w = World::new(); + let mut f = flags(); + w.enter_map(&c, &f, 1, 2, 2, spec::dir::DOWN); + assert!(w.actors[1].visible); + set_flag(&mut f, 3, true); + w.enter_map(&c, &f, 1, 2, 2, spec::dir::DOWN); + assert!(!w.actors[1].visible, "the gate flag hides the actor"); + } + + #[test] + fn actors_do_not_stack_on_one_cell() { + let mut m = open_map(1, 4, 4); + m.actors.push(ActorDef { x: 2, y: 1, flag_gate: 0xffff, ..Default::default() }); + let c = content_with(vec![m]); + let mut w = World::new(); + w.enter_map(&c, &flags(), 1, 2, 2, spec::dir::UP); + run(&mut w, &c, 40, spec::btn::UP, 0); + assert_eq!((w.player().cx, w.player().cy), (2, 2), "blocked by the NPC"); + } + + #[test] + fn flag_bits_round_trip_and_ignore_overflow() { + let mut f = flags(); + assert!(!flag_set(&f, 0)); + set_flag(&mut f, 0, true); + set_flag(&mut f, 9, true); + assert!(flag_set(&f, 0) && flag_set(&f, 9)); + assert!(!flag_set(&f, 1)); + set_flag(&mut f, 9, false); + assert!(!flag_set(&f, 9)); + // Out of range is a no-op, never a panic. + set_flag(&mut f, 60000, true); + assert!(!flag_set(&f, 60000)); + } + + #[test] + fn camera_centres_on_the_player() { + let c = content_with(vec![open_map(1, 8, 8)]); + let mut w = World::new(); + w.enter_map(&c, &flags(), 1, 4, 4, spec::dir::DOWN); + let (px, py) = w.player().pixel_pos(); + assert_eq!(w.cam_x, px + spec::CELL_PX / 2 - spec::VIEW_W / 2); + assert_eq!(w.cam_y, py + spec::CELL_PX / 2 - spec::VIEW_H / 2); + } +} diff --git a/engine/pocketmon/crates/pocketmon-gu/Cargo.toml b/engine/pocketmon/crates/pocketmon-gu/Cargo.toml new file mode 100644 index 00000000..a18372f2 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-gu/Cargo.toml @@ -0,0 +1,19 @@ +# pocketmon-gu — the sceGu (PSP GE) backend for Pocket Mon's draw list. +# +# STANDALONE CRATE: excluded from the engine workspace — it only compiles for +# mipsel-sony-psp (tier-3, cargo-psp toolchain), and workspace membership would +# break desktop `cargo check/test`. Consumed as a path dep by the EBOOT next to +# `pocketmon-core`, exactly the way `pocket3d-gu` sits beside `pocket3d-bsp`. +# +# The `psp` revision mirrors hosts/psp/Cargo.toml and tools/cli/psp-toolchain.json; +# no sibling checkout is part of the build contract. + +[package] +name = "pocketmon-gu" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[dependencies] +psp = { git = "https://github.com/pocket-stack/rust-psp.git", rev = "2cbaf8c9bc72569c76240a1d9743de10731e5f6b", features = ["abort-only", "external-global-alloc"] } +pocketmon-core = { path = "../pocketmon-core" } diff --git a/engine/pocketmon/crates/pocketmon-gu/src/lib.rs b/engine/pocketmon/crates/pocketmon-gu/src/lib.rs new file mode 100644 index 00000000..c509bcec --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-gu/src/lib.rs @@ -0,0 +1,330 @@ +//! The PSP GE backend for [`MonDrawList`]. +//! +//! One job: turn the core's ordered command stream into GE sprite primitives, +//! with the same result the software rasterizer in `pocketmon-sim` produces. +//! The two are checked against each other by the frame goldens, so anything +//! clever here has to be clever identically in both places — which is a good +//! reason for there to be nothing clever here at all. +//! +//! ## Batching +//! +//! The draw list is one ordered stream (see `draw.rs` for why), so this walks +//! it and flushes a batch whenever the *kind* changes (textured vs flat) or +//! the atlas page changes. A typical frame is: one big terrain batch, a few +//! actor quads, then alternating panel/text runs — a handful of state changes, +//! not one per quad. +//! +//! ## The two GE facts that bite +//! +//! - **Textures must be 16-byte aligned and dcache-written-back.** The GE +//! reads RAM directly, not the CPU's cache, so a page uploaded but not +//! flushed renders as whatever was in RAM before. +//! - **`sceGuTexImage` does not invalidate the hardware texture cache.** Binds +//! that only change the pointer (same size, same format — which is every one +//! of our 256x256 pages) sample stale lines on real hardware while every +//! emulator hides it. `sceGuTexFlush` on every bind. + +#![no_std] + +extern crate alloc; + +use alloc::vec::Vec; +use core::ffi::c_void; +use core::ptr::null; + +use pocketmon_core::draw::{DrawCmd, MonDrawList, Quad, Rect}; +use pocketmon_core::spec; +use pocketmon_core::Content; + +use psp::sys::{ + self, ClutPixelFormat, GuPrimitive, GuState, MipmapLevel, TextureColorComponent, TextureEffect, + TextureFilter, TexturePixelFormat, VertexType, +}; + +/// Integer scale from the core's logical view to the PSP panel. +/// 240x136 -> 480x272 (docs/MON.md §4). +pub const SCALE: i16 = 2; + +// The GE's vertex strides. Asserted rather than assumed: a silent change here +// is the single most confusing rendering bug this backend can have. +const _: () = assert!(core::mem::size_of::() == 16); +const _: () = assert!(core::mem::size_of::() == 12); + +/// Max vertices per `sceGuDrawArray`: the GE PRIM command packs the count into +/// its low 16 bits, so a bigger batch would corrupt the primitive field. Even +/// and divisible by 2, preserving sprite-pair granularity. +const MAX_PRIM_VERTS: i32 = 65532; + +/// A textured sprite vertex. Field order IS the wire order the GE reads. +/// +/// `repr(C)`, deliberately NOT `packed`. The GE pads a vertex to the alignment +/// of its largest component, so this layout is u16 u, u16 v, (align) u32 +/// colour, i16 x/y/z, and a trailing 2 bytes — 16 bytes, not the 14 a packed +/// struct would give. Getting that wrong does not fail to compile or crash: it +/// draws a screen of plausible-looking garbage, because every vertex after the +/// first reads two bytes into its predecessor. +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct TexVert { + u: i16, + v: i16, + color: u32, + x: i16, + y: i16, + z: i16, +} + +/// An untextured sprite vertex: u32 colour then i16 x/y/z, padded to 12. +#[repr(C)] +#[derive(Clone, Copy, Default)] +struct FlatVert { + color: u32, + x: i16, + y: i16, + z: i16, +} + +/// A 16-byte-aligned copy of one atlas page, in a form the GE can sample. +struct Page { + w: u16, + h: u16, + /// Owned, over-allocated so the used slice starts on a 16-byte boundary. + backing: Vec, + offset: usize, +} + +impl Page { + fn ptr(&self) -> *const c_void { + unsafe { self.backing.as_ptr().add(self.offset) as *const c_void } + } + + fn len(&self) -> usize { + self.w as usize * self.h as usize + } +} + +/// The backend: uploaded pages, the shared CLUT, and the per-frame vertex +/// scratch. +pub struct Backend { + pages: Vec, + /// The one 256-entry ABGR palette every page samples through, kept + /// 16-byte aligned for `sceGuClutLoad`. + clut: Vec, + clut_offset: usize, + tex: Vec, + flat: Vec, +} + +impl Default for Backend { + fn default() -> Self { + Backend::new() + } +} + +impl Backend { + pub fn new() -> Self { + Backend { + pages: Vec::new(), + clut: Vec::new(), + clut_offset: 0, + tex: Vec::new(), + flat: Vec::new(), + } + } + + /// Copy the loaded content's atlas and palette into GE-visible memory. + /// + /// Called once after `load_content`. The core's own `Vec` pages cannot + /// be handed to the GE directly: `Vec` guarantees only 1-byte alignment, + /// and the texture unit needs 16. + pub fn upload(&mut self, content: &Content) { + self.pages.clear(); + for src in &content.pages { + let len = src.pixels.len(); + let mut backing = alloc::vec![0u8; len + 16]; + let base = backing.as_ptr() as usize; + let offset = (16 - (base % 16)) % 16; + backing[offset..offset + len].copy_from_slice(&src.pixels); + let page = Page { w: src.w, h: src.h, backing, offset }; + unsafe { + sys::sceKernelDcacheWritebackRange(page.ptr(), len as u32); + } + self.pages.push(page); + } + + self.clut = alloc::vec![0u32; spec::PALETTE_ENTRIES + 4]; + let base = self.clut.as_ptr() as usize; + self.clut_offset = ((16 - (base % 16)) % 16) / 4; + for (i, &c) in content.palette.iter().take(spec::PALETTE_ENTRIES).enumerate() { + self.clut[self.clut_offset + i] = c; + } + unsafe { + sys::sceKernelDcacheWritebackRange( + self.clut.as_ptr().add(self.clut_offset) as *const c_void, + (spec::PALETTE_ENTRIES * 4) as u32, + ); + } + } + + /// Re-upload just the palette — the cheap way to do a screen-wide colour + /// effect, exactly as the handheld did it. + pub fn refresh_palette(&mut self, palette: &[u32]) { + for (i, &c) in palette.iter().take(spec::PALETTE_ENTRIES).enumerate() { + self.clut[self.clut_offset + i] = c; + } + unsafe { + sys::sceKernelDcacheWritebackRange( + self.clut.as_ptr().add(self.clut_offset) as *const c_void, + (spec::PALETTE_ENTRIES * 4) as u32, + ); + } + } + + /// Draw one frame. The caller owns `sceGuStart`/`sceGuFinish`. + pub fn draw(&mut self, list: &MonDrawList) { + self.tex.clear(); + self.flat.clear(); + + // Walk the stream, flushing whenever the batch's kind or page changes. + let mut page: i32 = -1; + for item in &list.items { + match item { + DrawCmd::Quad(q) => { + if !self.flat.is_empty() { + self.flush_flat(); + } + if page >= 0 && page != q.page as i32 { + self.flush_tex(page as usize); + } + page = q.page as i32; + push_quad(&mut self.tex, q); + } + DrawCmd::Rect(r) => { + if page >= 0 { + self.flush_tex(page as usize); + page = -1; + } + push_rect(&mut self.flat, r); + } + } + } + if page >= 0 { + self.flush_tex(page as usize); + } + self.flush_flat(); + } + + fn flush_tex(&mut self, page: usize) { + if self.tex.is_empty() { + return; + } + unsafe { + if let Some(p) = self.pages.get(page) { + sys::sceGuEnable(GuState::Texture2D); + sys::sceGuClutMode(ClutPixelFormat::Psm8888, 0, 0xff, 0); + sys::sceGuClutLoad(32, self.clut.as_ptr().add(self.clut_offset) as *const c_void); + sys::sceGuTexMode(TexturePixelFormat::PsmT8, 0, 0, 0); + sys::sceGuTexImage(MipmapLevel::None, p.w as i32, p.h as i32, p.w as i32, p.ptr()); + // See the module docs: the hardware texture cache is NOT + // invalidated by a rebind, and every one of our pages has the + // same dimensions, so this is exactly the case that breaks. + sys::sceGuTexFlush(); + sys::sceGuTexFunc(TextureEffect::Modulate, TextureColorComponent::Rgba); + // Nearest, always: the goldens are defined against the + // software rasterizer, and bilinear would bleed across atlas + // cells that belong to different frames entirely. + sys::sceGuTexFilter(TextureFilter::Nearest, TextureFilter::Nearest); + let _ = p.len(); + } + let count = self.tex.len() as i32; + let bytes = core::mem::size_of::() * self.tex.len(); + flush( + GuPrimitive::Sprites, + VertexType::TEXTURE_16BIT + | VertexType::COLOR_8888 + | VertexType::VERTEX_16BIT + | VertexType::TRANSFORM_2D, + count, + self.tex.as_ptr() as *const c_void, + bytes, + ); + } + self.tex.clear(); + } + + fn flush_flat(&mut self) { + if self.flat.is_empty() { + return; + } + unsafe { + sys::sceGuDisable(GuState::Texture2D); + let count = self.flat.len() as i32; + let bytes = core::mem::size_of::() * self.flat.len(); + flush( + GuPrimitive::Sprites, + VertexType::COLOR_8888 | VertexType::VERTEX_16BIT | VertexType::TRANSFORM_2D, + count, + self.flat.as_ptr() as *const c_void, + bytes, + ); + } + self.flat.clear(); + } +} + +/// A sprite primitive is two vertices: top-left and bottom-right. +fn push_quad(out: &mut Vec, q: &Quad) { + let (mut u0, mut u1) = (q.u as i16, q.u as i16 + q.w as i16); + let (mut v0, mut v1) = (q.v as i16, q.v as i16 + q.h as i16); + // Flipping mirrors the source read, which for a sprite primitive means + // swapping the texture coordinates rather than the screen ones. + if q.flags & spec::QUAD_FLAG_FLIP_X != 0 { + core::mem::swap(&mut u0, &mut u1); + } + if q.flags & spec::QUAD_FLAG_FLIP_Y != 0 { + core::mem::swap(&mut v0, &mut v1); + } + let x0 = q.x * SCALE; + let y0 = q.y * SCALE; + let x1 = x0 + q.w as i16 * SCALE; + let y1 = y0 + q.h as i16 * SCALE; + out.push(TexVert { u: u0, v: v0, color: q.tint, x: x0, y: y0, z: 0 }); + out.push(TexVert { u: u1, v: v1, color: q.tint, x: x1, y: y1, z: 0 }); +} + +fn push_rect(out: &mut Vec, r: &Rect) { + let x0 = r.x * SCALE; + let y0 = r.y * SCALE; + let x1 = x0 + r.w as i16 * SCALE; + let y1 = y0 + r.h as i16 * SCALE; + out.push(FlatVert { color: r.color, x: x0, y: y0, z: 0 }); + out.push(FlatVert { color: r.color, x: x1, y: y1, z: 0 }); +} + +/// dcache-writeback a vertex batch, then enqueue its draw — chunked so no +/// single PRIM exceeds the 16-bit vertex-count field. +unsafe fn flush( + prim: GuPrimitive, + vtype: VertexType, + count: i32, + verts: *const c_void, + bytes: usize, +) { + if count <= 0 { + return; + } + sys::sceKernelDcacheWritebackRange(verts, bytes as u32); + let stride = bytes / count as usize; + let mut done: i32 = 0; + while done < count { + let n = (count - done).min(MAX_PRIM_VERTS); + sys::sceGuDrawArray( + prim, + vtype, + n, + null(), + (verts as *const u8).add(done as usize * stride) as *const c_void, + ); + done += n; + } +} diff --git a/engine/pocketmon/crates/pocketmon-psp/Cargo.lock b/engine/pocketmon/crates/pocketmon-psp/Cargo.lock new file mode 100644 index 00000000..609905d5 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-psp/Cargo.lock @@ -0,0 +1,123 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pocketmon-core" +version = "0.1.0" + +[[package]] +name = "pocketmon-gu" +version = "0.1.0" +dependencies = [ + "pocketmon-core", + "psp", +] + +[[package]] +name = "pocketmon-psp" +version = "0.1.0" +dependencies = [ + "pocketmon-core", + "pocketmon-gu", + "psp", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psp" +version = "0.3.12" +source = "git+https://github.com/pocket-stack/rust-psp.git?rev=2cbaf8c9bc72569c76240a1d9743de10731e5f6b#2cbaf8c9bc72569c76240a1d9743de10731e5f6b" +dependencies = [ + "bitflags", + "libm", + "num_enum", + "num_enum_derive", + "paste", + "unstringify", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unstringify" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9612d66420ead229348915b911ad9689e79dfc347fe7a876a82551c8eab36b5e" diff --git a/engine/pocketmon/crates/pocketmon-psp/Cargo.toml b/engine/pocketmon/crates/pocketmon-psp/Cargo.toml new file mode 100644 index 00000000..28e1a207 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-psp/Cargo.toml @@ -0,0 +1,43 @@ +# pocketmon-psp — the Pocket Mon EBOOT. +# +# STANDALONE LONE-BIN CRATE: cargo-psp requires the binary crate to be outside +# any workspace (the same rule hosts/psp lives by). Build it with +# `bun tools/mon.ts psp`, which resolves the pinned toolchain from +# tools/cli/psp-toolchain.json. +# +# The composition, in docs/RUNTIMES.md terms: +# pocketmon-core the RPG core (world, battle, script VM, save, draw list) +# + pocketmon-gu the GE backend for that draw list +# + this crate the arena allocator, the frame loop, and the input mapping +# +# The cooked content pak is embedded at build time, so the EBOOT is one file +# with no companion data on the stick. + +[package] +name = "pocketmon-psp" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[dependencies] +# `external-global-alloc` gates out rust-psp's own #[global_allocator] (one +# kernel object per allocation) and its #[alloc_error_handler], so src/arena.rs +# can provide both — the same trade hosts/psp makes for the same reason. +psp = { git = "https://github.com/pocket-stack/rust-psp.git", rev = "2cbaf8c9bc72569c76240a1d9743de10731e5f6b", features = ["abort-only", "external-global-alloc"] } +pocketmon-core = { path = "../pocketmon-core" } +pocketmon-gu = { path = "../pocketmon-gu" } + +[features] +# Frame-dump build for tests/e2e/mon-ppsspp.ts: writes captured frames to +# ms0:/mon_cap and replays a baked input tape. Never in a normal build. +capture = [] + +[[bin]] +name = "pocketmon-psp" +path = "src/main.rs" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 +panic = "abort" diff --git a/engine/pocketmon/crates/pocketmon-psp/Psp.toml b/engine/pocketmon/crates/pocketmon-psp/Psp.toml new file mode 100644 index 00000000..26d78ae6 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-psp/Psp.toml @@ -0,0 +1,3 @@ +# Generated by tools/mon.ts — cargo-psp reads this for the PBP header. +[psp] +title = "SPARKWOOD" diff --git a/engine/pocketmon/crates/pocketmon-psp/src/arena.rs b/engine/pocketmon/crates/pocketmon-psp/src/arena.rs new file mode 100644 index 00000000..83b9fa8d --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-psp/src/arena.rs @@ -0,0 +1,198 @@ +//! The EBOOT's global allocator: one static arena with a first-fit free list. +//! +//! rust-psp's default `SystemAlloc` asks the kernel for a *partition block per +//! allocation*. That is fine for a demo that allocates a dozen times; this core +//! parses a content pak into several thousand `Vec`s and `String`s at boot and +//! would exhaust the kernel object table long before it finished. Same +//! conclusion the 2D runtime reached (docs/DESIGN.md "Memory (the blocker +//! fix)"), reached again here — so this crate carries its own small arena +//! rather than linking the whole 2D host to borrow its one. +//! +//! The arena is a `static mut` in `.bss`, which costs nothing in the EBOOT +//! file (bss is zero-filled at load) and nothing in kernel objects. +//! +//! Design: a singly linked free list of blocks, each with an 8-byte header, +//! first-fit, splitting on allocate and coalescing forward on free. Not the +//! fastest allocator in the world; allocation is a boot-time cost here and the +//! steady-state frame allocates nothing (the draw list and vertex buffers keep +//! their capacity). + +use core::alloc::{GlobalAlloc, Layout}; +use core::ptr; + +/// Arena size. Measured need at boot is ~1.6 MB (a 330 kB pak parsed into +/// registries, plus a 16-byte-aligned copy of every atlas page); 6 MB leaves +/// room for content to grow several times over and still fits comfortably in +/// the PSP's 24 MB of user RAM. +pub const ARENA_BYTES: usize = 6 * 1024 * 1024; + +#[repr(C, align(16))] +struct Arena([u8; ARENA_BYTES]); + +static mut ARENA: Arena = Arena([0; ARENA_BYTES]); + +/// Block header. `size` counts the payload only; blocks are 16-byte aligned so +/// the header is 16 bytes to keep payloads aligned too. +#[repr(C, align(16))] +struct Header { + size: usize, + next_free: *mut Header, + free: bool, +} + +const HEADER: usize = core::mem::size_of::
(); +const ALIGN: usize = 16; + +fn round_up(v: usize, a: usize) -> usize { + (v + a - 1) & !(a - 1) +} + +pub struct ArenaAlloc { + head: spin::Mutex<*mut Header>, +} + +// The PSP EBOOT runs the guest on a single worker thread, but interrupts and +// the audio callback can allocate; the spin lock keeps that honest. +unsafe impl Sync for ArenaAlloc {} +unsafe impl Send for ArenaAlloc {} + +impl ArenaAlloc { + pub const fn new() -> Self { + ArenaAlloc { head: spin::Mutex::new(ptr::null_mut()) } + } + + /// Lay the whole arena out as one free block. Idempotent. + unsafe fn init(&self) -> *mut Header { + let mut head = self.head.lock(); + if !(*head).is_null() { + return *head; + } + let base = ptr::addr_of_mut!(ARENA) as *mut u8; + let h = base as *mut Header; + (*h).size = ARENA_BYTES - HEADER; + (*h).next_free = ptr::null_mut(); + (*h).free = true; + *head = h; + h + } + + /// Bytes still free, for the boot-time stats line. + pub fn free_bytes(&self) -> usize { + unsafe { + let mut total = 0; + let mut cur = *self.head.lock(); + while !cur.is_null() { + if (*cur).free { + total += (*cur).size; + } + cur = (*cur).next_free; + } + total + } + } +} + +unsafe impl GlobalAlloc for ArenaAlloc { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + let want = round_up(layout.size().max(1), ALIGN).max(layout.align()); + self.init(); + let head = self.head.lock(); + let mut cur = *head; + while !cur.is_null() { + if (*cur).free && (*cur).size >= want { + // Split when the tail is big enough to be worth a header. + if (*cur).size >= want + HEADER + ALIGN { + let next = (cur as *mut u8).add(HEADER + want) as *mut Header; + (*next).size = (*cur).size - want - HEADER; + (*next).next_free = (*cur).next_free; + (*next).free = true; + (*cur).size = want; + (*cur).next_free = next; + } + (*cur).free = false; + return (cur as *mut u8).add(HEADER); + } + cur = (*cur).next_free; + } + // Out of arena. Returning null makes Rust call the alloc error handler, + // which halts with a message rather than corrupting memory. + ptr::null_mut() + } + + unsafe fn dealloc(&self, p: *mut u8, _layout: Layout) { + if p.is_null() { + return; + } + let h = p.sub(HEADER) as *mut Header; + let _guard = self.head.lock(); + (*h).free = true; + // Coalesce forward as far as the run of free neighbours goes, so a + // long-lived pattern of grow-and-free (every `Vec` push past capacity) + // does not shred the arena into unusable slivers. + loop { + let next = (*h).next_free; + if next.is_null() || !(*next).free { + break; + } + let adjacent = (h as *mut u8).add(HEADER + (*h).size) as *mut Header == next; + if !adjacent { + break; + } + (*h).size += HEADER + (*next).size; + (*h).next_free = (*next).next_free; + } + } +} + +/// A minimal spin lock — `spin` the crate is not vendored, and this needs +/// exactly one primitive. +mod spin { + use core::cell::UnsafeCell; + use core::ops::{Deref, DerefMut}; + use core::sync::atomic::{AtomicBool, Ordering}; + + pub struct Mutex { + locked: AtomicBool, + value: UnsafeCell, + } + + impl Mutex { + pub const fn new(value: T) -> Self { + Mutex { locked: AtomicBool::new(false), value: UnsafeCell::new(value) } + } + + pub fn lock(&self) -> Guard<'_, T> { + while self + .locked + .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + core::hint::spin_loop(); + } + Guard { lock: self } + } + } + + pub struct Guard<'a, T> { + lock: &'a Mutex, + } + + impl Deref for Guard<'_, T> { + type Target = T; + fn deref(&self) -> &T { + unsafe { &*self.lock.value.get() } + } + } + + impl DerefMut for Guard<'_, T> { + fn deref_mut(&mut self) -> &mut T { + unsafe { &mut *self.lock.value.get() } + } + } + + impl Drop for Guard<'_, T> { + fn drop(&mut self) { + self.lock.locked.store(false, Ordering::Release); + } + } +} diff --git a/engine/pocketmon/crates/pocketmon-psp/src/capture.rs b/engine/pocketmon/crates/pocketmon-psp/src/capture.rs new file mode 100644 index 00000000..cea5d5cd --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-psp/src/capture.rs @@ -0,0 +1,145 @@ +//! The frame-dump build (`--features capture`). +//! +//! Exactly the mechanism the 2D host uses (`hosts/psp/src/main.rs`): bake a +//! scripted input tape and a capture window into the EBOOT, run it under +//! PPSSPPHeadless's software renderer, and write the presented framebuffer to +//! `ms0:/mon_cap/fNNNN.raw` for each frame in the window. +//! +//! Why bake the tape in rather than read it from a file: the emulator's +//! filesystem is one more thing between "the run" and "the golden", and a +//! baked tape makes the EBOOT itself the complete description of the run. +//! +//! Never compiled into a normal build. + +use core::ffi::c_void; + +use psp::sys::{self, DisplayPixelFormat, DisplaySetBufSync, IoOpenFlags}; + +/// `"frame:mask,frame:mask,…"` — the active mask is the last threshold at or +/// before the current frame. Baked by tools/mon.ts from the e2e spec. +const INPUT: &str = env!("MON_CAPTURE_INPUT"); +const CAP_START: &str = env!("MON_CAP_START"); +const CAP_N: &str = env!("MON_CAP_N"); + +fn env_u32(s: &str, default: u32) -> u32 { + let mut v: u32 = 0; + let mut any = false; + for b in s.bytes() { + if !b.is_ascii_digit() { + return default; + } + v = v.wrapping_mul(10).wrapping_add((b - b'0') as u32); + any = true; + } + if any { + v + } else { + default + } +} + +pub fn cap_start() -> u32 { + env_u32(CAP_START, 0) +} + +pub fn cap_n() -> u32 { + env_u32(CAP_N, 64) +} + +/// Parse one `frame:mask` pair. Masks may be decimal or `0x`-prefixed. +fn parse_pair(pair: &str) -> Option<(u32, u32)> { + let (frame, mask) = pair.split_once(':')?; + let frame = env_u32(frame.trim(), u32::MAX); + if frame == u32::MAX { + return None; + } + let mask = mask.trim(); + let value = if let Some(hex) = mask.strip_prefix("0x").or_else(|| mask.strip_prefix("0X")) { + let mut v: u32 = 0; + for b in hex.bytes() { + let d = match b { + b'0'..=b'9' => b - b'0', + b'a'..=b'f' => b - b'a' + 10, + b'A'..=b'F' => b - b'A' + 10, + _ => return None, + }; + v = v.wrapping_mul(16).wrapping_add(d as u32); + } + v + } else { + env_u32(mask, 0) + }; + Some((frame, value)) +} + +/// The scripted button mask for a frame: the last threshold at or before it. +pub fn scripted_buttons(frame: u32) -> u32 { + let mut best_frame = 0; + let mut best = 0; + let mut seen = false; + for pair in INPUT.split(',') { + let pair = pair.trim(); + if pair.is_empty() { + continue; + } + let Some((at, mask)) = parse_pair(pair) else { continue }; + if at <= frame && (!seen || at >= best_frame) { + best_frame = at; + best = mask; + seen = true; + } + } + best +} + +/// Dump the presented framebuffer for frames inside the window, then exit the +/// game on the last one so the headless run terminates on its own. +pub unsafe fn dump_frame(frame: u32) { + let start = cap_start(); + let n = cap_n(); + if frame < start || frame >= start + n { + return; + } + let idx = frame - start; + if idx == 0 { + sys::sceIoMkdir(b"ms0:/mon_cap\0".as_ptr(), 0o777); + } + // "ms0:/mon_cap/fNNNN.raw\0", digits at offsets 14..=17. + let mut name: [u8; 23] = *b"ms0:/mon_cap/f0000.raw\0"; + let mut v = idx; + let mut i = 17usize; + loop { + name[i] = b'0' + (v % 10) as u8; + v /= 10; + if i == 14 { + break; + } + i -= 1; + } + + // Read straight from VRAM through the uncached mirror: the GE's output is + // not in the CPU's cache, and reading the cached view yields whatever was + // there before. + let mut top: *mut c_void = core::ptr::null_mut(); + let mut bw: usize = 0; + let mut fmt = DisplayPixelFormat::Psm8888; + sys::sceDisplayGetFrameBuf(&mut top, &mut bw, &mut fmt, DisplaySetBufSync::Immediate); + let mut addr = top as u32; + if addr < 0x0400_0000 { + addr += 0x0400_0000; + } + addr |= 0x4000_0000; + + let fd = sys::sceIoOpen( + name.as_ptr(), + IoOpenFlags::CREAT | IoOpenFlags::WR_ONLY | IoOpenFlags::TRUNC, + 0o777, + ); + if fd.0 >= 0 { + sys::sceIoWrite(fd, addr as *const c_void, 512 * 272 * 4); + sys::sceIoClose(fd); + } + if idx + 1 == n { + sys::sceKernelExitGame(); + } +} diff --git a/engine/pocketmon/crates/pocketmon-psp/src/main.rs b/engine/pocketmon/crates/pocketmon-psp/src/main.rs new file mode 100644 index 00000000..ee3e5387 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-psp/src/main.rs @@ -0,0 +1,233 @@ +//! SPARKWOOD on the PSP. +//! +//! The whole EBOOT: an arena allocator, a 480x272 GU init, the fixed-step +//! frame loop, and the button mapping. Everything else is +//! [`pocketmon_core`] — the same crate the headless simulator runs, compiled +//! for `mipsel-sony-psp` instead of the host, which is the point: the goldens +//! recorded on a laptop describe this console's output because there is only +//! one implementation of the rules. +//! +//! ```text +//! D-pad / analog walk, move a cursor +//! CROSS confirm (A) +//! CIRCLE cancel (B) +//! START menu +//! SELECT the debug overlay +//! ``` +//! +//! CROSS is confirm rather than CIRCLE: this is a Western-market handheld +//! convention and matches every other PocketJS host in the repo. + +#![no_std] +#![no_main] +// The arena is this crate's own global allocator (see src/arena.rs), and the +// FPU-status reset below is one MIPS instruction with no stable spelling. +#![feature(alloc_error_handler)] +#![feature(asm_experimental_arch)] + +extern crate alloc; + +mod arena; +#[cfg(feature = "capture")] +mod capture; + +use core::ffi::c_void; + +use pocketmon_core::{spec, Game}; +use pocketmon_gu::Backend; + +use psp::sys::{ + self, CtrlButtons, CtrlMode, DisplayPixelFormat, GuContextType, GuState, GuSyncBehavior, + GuSyncMode, SceCtrlData, ShadingModel, +}; +use psp::vram_alloc::get_vram_allocator; +use psp::{Align16, BUF_WIDTH, SCREEN_HEIGHT, SCREEN_WIDTH}; + +psp::module!("sparkwood", 1, 0); + +#[global_allocator] +static ALLOC: arena::ArenaAlloc = arena::ArenaAlloc::new(); + +#[alloc_error_handler] +fn on_oom(layout: core::alloc::Layout) -> ! { + psp::dprintln!("[sparkwood] out of arena: wanted {} bytes", layout.size()); + psp::dprintln!("HOME exits."); + loop { + unsafe { sys::sceDisplayWaitVblankStart() }; + } +} + +/// The cooked game, baked into the binary. One file on the stick, no companion +/// data to lose — and it makes the EBOOT self-describing for the goldens. +static PAK: &[u8] = include_bytes!(env!("SPARKWOOD_PAK")); + +/// GE display list buffer (512 kB), 16-byte aligned. +static mut LIST: Align16<[u32; 0x20000]> = Align16([0; 0x20000]); + +/// The analog stick reads 0..255 with 128 at rest; this much off-centre counts +/// as a direction. Generous, because the nub on a well-used PSP does not sit +/// exactly at centre. +const NUB_DEADZONE: i32 = 48; + +fn psp_main() { + unsafe { + psp::enable_home_button(); + // Real hardware can start a PSPLINK-loaded thread with FPU exceptions + // unmasked. The core is integer-only, but the SDK is not; clear FCSR + // so a stray NaN in library code does not trap to a black screen. + core::arch::asm!("ctc1 $zero, $31", options(nostack, nomem)); + init_graphics(); + sys::sceCtrlSetSamplingCycle(0); + sys::sceCtrlSetSamplingMode(CtrlMode::Analog); + run(); + } +} + +unsafe fn init_graphics() { + let Ok(allocator) = get_vram_allocator() else { + halt("get_vram_allocator failed"); + }; + let fbp0 = allocator + .alloc_texture_pixels(BUF_WIDTH, SCREEN_HEIGHT, sys::TexturePixelFormat::Psm8888) + .as_mut_ptr_from_zero(); + let fbp1 = allocator + .alloc_texture_pixels(BUF_WIDTH, SCREEN_HEIGHT, sys::TexturePixelFormat::Psm8888) + .as_mut_ptr_from_zero(); + + sys::sceGuInit(); + sys::sceGuStart(GuContextType::Direct, list_ptr()); + sys::sceGuDrawBuffer(DisplayPixelFormat::Psm8888, fbp0 as _, BUF_WIDTH as i32); + sys::sceGuDispBuffer(SCREEN_WIDTH as i32, SCREEN_HEIGHT as i32, fbp1 as _, BUF_WIDTH as i32); + sys::sceGuOffset(2048 - (SCREEN_WIDTH / 2), 2048 - (SCREEN_HEIGHT / 2)); + sys::sceGuViewport(2048, 2048, SCREEN_WIDTH as i32, SCREEN_HEIGHT as i32); + sys::sceGuScissor(0, 0, SCREEN_WIDTH as i32, SCREEN_HEIGHT as i32); + sys::sceGuEnable(GuState::ScissorTest); + // Alpha blending for the warp fade, which is the only translucency drawn. + sys::sceGuEnable(GuState::Blend); + sys::sceGuBlendFunc( + sys::BlendOp::Add, + sys::BlendFactor::SrcAlpha, + sys::BlendFactor::OneMinusSrcAlpha, + 0, + 0, + ); + sys::sceGuShadeModel(ShadingModel::Flat); + sys::sceGuFinish(); + sys::sceGuSync(GuSyncMode::Finish, GuSyncBehavior::Wait); + sys::sceDisplayWaitVblankStart(); + sys::sceGuDisplay(true); +} + +fn list_ptr() -> *mut c_void { + core::ptr::addr_of_mut!(LIST) as *mut c_void +} + +unsafe fn halt(msg: &str) -> ! { + psp::dprintln!("[sparkwood halt] {msg}"); + psp::dprintln!("HOME exits."); + loop { + sys::sceDisplayWaitVblankStart(); + } +} + +unsafe fn run() -> ! { + let mut game = Game::new(); + // A fixed seed: the console has no entropy source the goldens could + // tolerate, and a deterministic run is worth more than a surprising one. + // A real save picks its own seed up from the file. + game.seed(0x5041_524b); + + if !game.load_content(PAK) { + halt("the embedded content pak did not parse"); + } + + let mut backend = Backend::new(); + backend.upload(&game.content); + + psp::dprintln!( + "[sparkwood] {} species, {} maps, {} pages, {} kB arena free", + game.content.species.len(), + game.content.maps.len(), + game.content.pages.len(), + ALLOC.free_bytes() / 1024, + ); + + // Start where a new game starts: the first map the content declares. + let start = game.content.maps.keys().next().copied().unwrap_or(1); + game.enter_map(start, 3, 3, spec::dir::DOWN); + + let mut pad = SceCtrlData::default(); + let mut frame: u32 = 0; + loop { + sys::sceCtrlPeekBufferPositive(&mut pad, 1); + #[cfg(not(feature = "capture"))] + let buttons = map_buttons(&pad); + // A capture build ignores the pad entirely: the run has to be a pure + // function of the frame index for the goldens to mean anything. + #[cfg(feature = "capture")] + let buttons = capture::scripted_buttons(frame); + + game.tick(buttons); + // Drain the event batch the way a guest would, so the queue never + // sits at its cap silently dropping facts. + let _ = game.encode_events(); + game.render(); + + sys::sceGuStart(GuContextType::Direct, list_ptr()); + sys::sceGuClearColor(0xff10_1418); + sys::sceGuClear(sys::ClearBuffer::COLOR_BUFFER_BIT); + backend.draw(&game.draw); + sys::sceGuFinish(); + sys::sceGuSync(GuSyncMode::Finish, GuSyncBehavior::Wait); + sys::sceDisplayWaitVblankStart(); + sys::sceGuSwapBuffers(); + + #[cfg(feature = "capture")] + capture::dump_frame(frame); + frame = frame.wrapping_add(1); + } +} + +/// Map the console's pad onto the core's abstract button set. +#[cfg_attr(feature = "capture", allow(dead_code))] +fn map_buttons(pad: &SceCtrlData) -> u32 { + let mut mask = 0; + let b = pad.buttons; + if b.contains(CtrlButtons::UP) { + mask |= spec::btn::UP; + } + if b.contains(CtrlButtons::DOWN) { + mask |= spec::btn::DOWN; + } + if b.contains(CtrlButtons::LEFT) { + mask |= spec::btn::LEFT; + } + if b.contains(CtrlButtons::RIGHT) { + mask |= spec::btn::RIGHT; + } + if b.contains(CtrlButtons::CROSS) { + mask |= spec::btn::A; + } + if b.contains(CtrlButtons::CIRCLE) { + mask |= spec::btn::B; + } + if b.contains(CtrlButtons::START) { + mask |= spec::btn::START; + } + if b.contains(CtrlButtons::SELECT) { + mask |= spec::btn::SELECT; + } + + // The analog stick walks too. One axis at a time: the world is a grid, and + // a diagonal push should pick a lane rather than stutter between two. + let dx = pad.lx as i32 - 128; + let dy = pad.ly as i32 - 128; + if dx.abs() > NUB_DEADZONE || dy.abs() > NUB_DEADZONE { + if dx.abs() > dy.abs() { + mask |= if dx < 0 { spec::btn::LEFT } else { spec::btn::RIGHT }; + } else { + mask |= if dy < 0 { spec::btn::UP } else { spec::btn::DOWN }; + } + } + mask +} diff --git a/engine/pocketmon/crates/pocketmon-sim/Cargo.lock b/engine/pocketmon/crates/pocketmon-sim/Cargo.lock new file mode 100644 index 00000000..4a08be52 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-sim/Cargo.lock @@ -0,0 +1,14 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "pocketmon-core" +version = "0.1.0" + +[[package]] +name = "pocketmon-sim" +version = "0.1.0" +dependencies = [ + "pocketmon-core", +] diff --git a/engine/pocketmon/crates/pocketmon-sim/Cargo.toml b/engine/pocketmon/crates/pocketmon-sim/Cargo.toml new file mode 100644 index 00000000..4ac34b52 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-sim/Cargo.toml @@ -0,0 +1,25 @@ +# pocketmon-sim — the deterministic headless host for Pocket Mon. +# +# The verification harness docs/MON.md §6 requires: it loads a cooked MONPAK, +# replays a scripted input tape through pocketmon-core, rasterizes the draw +# list exactly as a real backend would, and reports frame hashes. Everything +# the PSP EBOOT does, minus the console. +# +# STANDALONE CRATE (excluded from the engine workspace) so it can depend on +# pocketmon-core the same way the PSP EBOOT does, with its own lockfile. +# +# No dependencies: the PNG writer is 100 lines of stored-deflate, which is a +# better trade than pulling an image crate into a hermetic build. + +[package] +name = "pocketmon-sim" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[dependencies] +pocketmon-core = { path = "../pocketmon-core", features = ["std"] } + +[[bin]] +name = "pocketmon-sim" +path = "src/main.rs" diff --git a/engine/pocketmon/crates/pocketmon-sim/src/main.rs b/engine/pocketmon/crates/pocketmon-sim/src/main.rs new file mode 100644 index 00000000..de286cda --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-sim/src/main.rs @@ -0,0 +1,614 @@ +//! pocketmon-sim — the deterministic headless host. +//! +//! ```text +//! pocketmon-sim [--tape ] [--shots ] [--frames N] +//! [--seed N] [--scale N] [--hashes ] [--assert] +//! ``` +//! +//! A tape is a list of intents, one per line: +//! +//! ```text +//! walk hold a direction until that many steps land +//! press one button press (edge-detected) +//! wait idle, for fades and message holds +//! mark capture the frame and hash it +//! ``` +//! +//! Blank lines and `#` comments are ignored. +//! +//! This is the harness docs/MON.md §6 calls for: with a fixed seed and a fixed +//! tape, the frame hashes are stable, so a behavioural regression shows up as +//! a diff rather than as a vibe. + +mod png; +mod raster; + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use pocketmon_core::{spec, Game}; + +/// One tape instruction. +/// +/// Tapes describe *intent* — "walk four north", "press A" — not frame counts. +/// An earlier version of this harness counted frames, and every tape broke the +/// moment the walk cadence or an arrival facing changed. Intent survives that; +/// frame counts only ever encoded a guess about the engine's internals. +enum Cmd { + /// Hold a direction until `cells` steps land (or the safety cap trips). + Walk { dir: u32, cells: u32 }, + /// One button press: held briefly, then released, so edge detection sees + /// exactly one press however long the hold is. + Press { buttons: u32 }, + /// Idle for N frames — fades, message holds, animation. + Wait { frames: u32 }, + /// Pace back and forth between two directions until a battle starts. + /// + /// Encounters are a per-step probability, so "walk into the grass until + /// something happens" is the honest way to test them. A fixed seed still + /// makes the result exact — the tape just does not have to know which + /// step it lands on. + Grind { a: u32, b: u32, cap: u32 }, + /// Fight the current battle to its end, always taking the first move. + /// + /// A battle is a conversation with menus in it; scripting one press at a + /// time makes a tape that breaks whenever a message is reworded. + Fight { cap: u32 }, + /// Press A until nothing is waiting to be read. + /// + /// Dialogue length is content, not choreography: counting presses in a + /// tape means every edit to a line breaks every tape after it. + Clear { cap: u32 }, + /// Capture and hash the current frame. + Mark { name: String }, +} + +/// Frames a single `walk` step is allowed before the harness gives up. Well +/// over one step's worth, so a bump reports rather than hangs. +const WALK_CAP: u32 = 60; + +fn parse_buttons(s: &str) -> u32 { + let mut mask = 0; + for ch in s.chars() { + mask |= match ch { + 'u' => spec::btn::UP, + 'd' => spec::btn::DOWN, + 'l' => spec::btn::LEFT, + 'r' => spec::btn::RIGHT, + 'a' => spec::btn::A, + 'b' => spec::btn::B, + 's' => spec::btn::START, + 'e' => spec::btn::SELECT, + _ => 0, + }; + } + mask +} + +fn parse_tape(src: &str) -> Result, String> { + let mut out = Vec::new(); + for (n, raw) in src.lines().enumerate() { + let line = raw.split('#').next().unwrap_or("").trim(); + if line.is_empty() { + continue; + } + let mut parts = line.split_whitespace(); + let verb = parts.next().unwrap_or(""); + let bad = |what: &str| Err(format!("line {}: {what}", n + 1)); + match verb { + "walk" => { + let Some(dir) = parts.next() else { return bad("walk needs a direction") }; + let cells: u32 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(1); + let mask = parse_buttons(dir); + if mask & (spec::btn::UP | spec::btn::DOWN | spec::btn::LEFT | spec::btn::RIGHT) == 0 + { + return bad("walk needs one of u/d/l/r"); + } + out.push(Cmd::Walk { dir: mask, cells }); + } + "press" => { + let Some(b) = parts.next() else { return bad("press needs buttons") }; + out.push(Cmd::Press { buttons: parse_buttons(b) }); + } + "wait" => { + let frames: u32 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(1); + out.push(Cmd::Wait { frames }); + } + "fight" => { + let cap: u32 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(200); + out.push(Cmd::Fight { cap }); + } + "clear" => { + let cap: u32 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(24); + out.push(Cmd::Clear { cap }); + } + "grind" => { + let Some(dirs) = parts.next() else { return bad("grind needs two directions") }; + let mut it = dirs.chars(); + let (Some(a), Some(b)) = (it.next(), it.next()) else { + return bad("grind needs two directions, e.g. `grind ud 400`"); + }; + let cap: u32 = parts.next().and_then(|v| v.parse().ok()).unwrap_or(600); + out.push(Cmd::Grind { + a: parse_buttons(&a.to_string()), + b: parse_buttons(&b.to_string()), + cap, + }); + } + "mark" => { + let Some(name) = parts.next() else { return bad("mark needs a name") }; + out.push(Cmd::Mark { name: name.to_string() }); + } + other => return bad(&format!("unknown command '{other}'")), + } + } + Ok(out) +} + +fn main() { + let args: Vec = std::env::args().skip(1).collect(); + if args.is_empty() { + eprintln!("usage: pocketmon-sim [--tape f] [--shots dir] [--frames n]"); + eprintln!(" [--seed n] [--scale n] [--hashes f] [--assert]"); + std::process::exit(2); + } + + let mut pak_path = PathBuf::new(); + let mut tape_path: Option = None; + let mut shots: Option = None; + let mut hashes_path: Option = None; + let mut frames = 0u32; + let mut seed = 0x5041_524bu64; // 'PARK' + let mut scale = 2u32; + let mut assert_mode = false; + let mut atlas_dir: Option = None; + + let mut i = 0; + while i < args.len() { + match args[i].as_str() { + "--tape" => { + i += 1; + tape_path = args.get(i).map(PathBuf::from); + } + "--shots" => { + i += 1; + shots = args.get(i).map(PathBuf::from); + } + "--hashes" => { + i += 1; + hashes_path = args.get(i).map(PathBuf::from); + } + "--frames" => { + i += 1; + frames = args.get(i).and_then(|v| v.parse().ok()).unwrap_or(0); + } + "--seed" => { + i += 1; + seed = args.get(i).and_then(|v| v.parse().ok()).unwrap_or(seed); + } + "--scale" => { + i += 1; + scale = args.get(i).and_then(|v| v.parse().ok()).unwrap_or(2).max(1); + } + "--atlas" => { + i += 1; + atlas_dir = args.get(i).map(PathBuf::from); + } + "--assert" => assert_mode = true, + other => { + if pak_path.as_os_str().is_empty() { + pak_path = PathBuf::from(other); + } + } + } + i += 1; + } + + let blob = match std::fs::read(&pak_path) { + Ok(b) => b, + Err(e) => { + eprintln!("cannot read {}: {e}", pak_path.display()); + std::process::exit(1); + } + }; + + let mut game = Game::new(); + game.seed(seed); + if !game.load_content(&blob) { + eprintln!("{}: not a valid MONPAK", pak_path.display()); + std::process::exit(1); + } + println!( + "loaded {}: {} species, {} moves, {} maps, {} strings, {} atlas pages", + pak_path.display(), + game.content.species.len(), + game.content.moves.len(), + game.content.maps.len(), + game.content.strings.len(), + game.content.pages.len(), + ); + + // Dumping the atlas is the fastest way to tell "the art is wrong" apart + // from "the drawing is wrong" — two failures that look identical on screen. + if let Some(dir) = &atlas_dir { + let _ = std::fs::create_dir_all(dir); + for (i, page) in game.content.pages.iter().enumerate() { + let mut rgba = vec![0u8; page.pixels.len() * 4]; + for (p, &idx) in page.pixels.iter().enumerate() { + let c = if idx == 0 { 0 } else { game.content.palette.get(idx as usize).copied().unwrap_or(0) }; + rgba[p * 4] = (c & 0xff) as u8; + rgba[p * 4 + 1] = ((c >> 8) & 0xff) as u8; + rgba[p * 4 + 2] = ((c >> 16) & 0xff) as u8; + rgba[p * 4 + 3] = ((c >> 24) & 0xff) as u8; + } + let path = dir.join(format!("page{i}.png")); + let _ = std::fs::write(&path, png::encode_rgba(page.w as u32, page.h as u32, &rgba)); + } + println!("dumped {} atlas pages to {}", game.content.pages.len(), dir.display()); + println!(" font page {} line height {}, {} glyphs", game.content.font_page, game.content.font_line_height, game.content.glyphs.len()); + } + + // Start where a new game starts. + let start = game.content.maps.keys().next().copied().unwrap_or(1); + game.enter_map(start, 3, 3, spec::dir::DOWN); + + let cmds = match &tape_path { + Some(p) => match std::fs::read_to_string(p) { + Ok(s) => match parse_tape(&s) { + Ok(c) => c, + Err(e) => { + eprintln!("{}: {e}", p.display()); + std::process::exit(1); + } + }, + Err(e) => { + eprintln!("cannot read tape {}: {e}", p.display()); + std::process::exit(1); + } + }, + None => vec![Cmd::Wait { frames: frames.max(1) }, Cmd::Mark { name: "end".into() }], + }; + + if let Some(dir) = &shots { + let _ = std::fs::create_dir_all(dir); + } + + // Stand in for the guest: drain the event batch every tick the way a real + // guest program does, so the queue never sits at its cap dropping facts. + let mut captured: BTreeMap = BTreeMap::new(); + let mut total = 0u32; + let mut stalled = 0u32; + + for cmd in &cmds { + match cmd { + Cmd::Wait { frames } => { + for _ in 0..*frames { + game.tick(0); + total += 1; + } + } + Cmd::Press { buttons } => { + // Held for a few frames, then released: the core edge-detects, + // so this is exactly one press no matter the hold length. + for _ in 0..4 { + game.tick(*buttons); + total += 1; + } + for _ in 0..8 { + game.tick(0); + total += 1; + } + } + Cmd::Walk { dir, cells } => { + let before = game.world.steps; + let start_map = game.world.map_id; + let cap = WALK_CAP * cells.max(&1); + let mut spent = 0; + loop { + let landed = game.world.steps.wrapping_sub(before); + // Holding a direction walks continuously: the frame a step + // lands is also the frame the next one starts. So the + // release has to happen once the in-flight step is the + // LAST one wanted, not after it lands — otherwise every + // walk overshoots by exactly one cell. + // `moving`, not `!idle`: turning to face a new direction is + // not a step, and counting it would end the walk before a + // single cell was covered. + let in_flight = u32::from(game.world.player().moving); + if landed + in_flight >= *cells || spent >= cap { + break; + } + game.tick(*dir); + total += 1; + spent += 1; + if game.world.map_id != start_map { + break; + } + } + total += settle(&mut game); + let walked = game.world.steps.wrapping_sub(before); + if walked < *cells && game.world.map_id == start_map { + println!( + " !! walk stalled after {walked}/{cells} cells at map {} ({}, {})", + game.world.map_id, + game.world.player().cx, + game.world.player().cy, + ); + stalled += 1; + } + } + Cmd::Clear { cap } => { + let mut pressed = 0; + while pressed < *cap && waiting_on_a(&game) { + for _ in 0..4 { + game.tick(spec::btn::A); + total += 1; + } + // Bail the FRAME the conversation ends, not at the end of a + // fixed idle window. Holding A past the last line re-opens + // the conversation — faithful behaviour, and an infinite + // loop for anything that presses A until the box is gone. + for _ in 0..12 { + if !waiting_on_a(&game) { + break; + } + game.tick(0); + total += 1; + } + pressed += 1; + if std::env::var_os("MON_TRACE").is_some() { + let what = game + .text + .current() + .map(|p| p.lines.join(" / ")) + .unwrap_or_else(|| "(closed)".into()); + println!(" clear {pressed}: {what}"); + } + } + if waiting_on_a(&game) { + println!(" !! clear gave up after {pressed} presses"); + stalled += 1; + } + total += settle(&mut game); + } + Cmd::Fight { cap } => { + let mut acted = 0; + while acted < *cap && game.battle.is_some() { + let phase = game.battle.as_ref().map(|b| b.phase).unwrap_or(0); + let button = match phase { + // FIGHT sits at cursor 0, and the move menu opens on + // slot 0, so A twice is "use the first move". + spec::phase::CHOOSE_ACTION | spec::phase::CHOOSE_MOVE => spec::btn::A, + // A forced switch: A takes whatever the cursor is on, + // which the core has already put on a healthy slot. + spec::phase::CHOOSE_SWITCH => spec::btn::A, + _ => spec::btn::A, + }; + for _ in 0..4 { + game.tick(button); + total += 1; + } + for _ in 0..12 { + game.tick(0); + total += 1; + } + acted += 1; + } + if game.battle.is_some() { + println!(" !! fight did not finish in {acted} actions"); + stalled += 1; + } + total += settle(&mut game); + } + Cmd::Grind { a, b, cap } => { + let mut spent = 0; + let mut dir = *a; + while spent < *cap && game.battle.is_none() { + let before = game.world.steps; + let mut stuck = 0; + // One step in the current direction, then flip. Flipping on + // a bump too, so a wall ends the leg instead of eating the + // whole budget. + loop { + let landed = game.world.steps.wrapping_sub(before); + let in_flight = u32::from(game.world.player().moving); + if landed + in_flight >= 1 || stuck > WALK_CAP || game.battle.is_some() { + break; + } + game.tick(dir); + total += 1; + spent += 1; + stuck += 1; + } + let settled = settle(&mut game); + total += settled; + spent += settled; + dir = if dir == *a { *b } else { *a }; + } + if game.battle.is_none() { + println!(" !! grind found no encounter in {spent} frames"); + stalled += 1; + } + } + Cmd::Mark { name } => { + // `render` borrows the whole game mutably to build the list; + // take the list out before reading content back for the raster. + game.render(); + let list = std::mem::take(&mut game.draw); + let frame = raster::render(&list, &game.content, scale); + game.draw = list; + let hash = format!("{:016x}", frame.hash()); + captured.insert(name.clone(), hash.clone()); + if let Some(dir) = &shots { + let path = dir.join(format!("{name}.png")); + let bytes = png::encode_rgba(frame.w, frame.h, &frame.px); + if let Err(e) = std::fs::write(&path, bytes) { + eprintln!("cannot write {}: {e}", path.display()); + } + } + println!( + " @{name:<20} f{total:<5} map {:<2} ({:>2},{:>2}) {:<10} party {} {}", + game.world.map_id, + game.world.player().cx, + game.world.player().cy, + mode_name(game.mode), + party_size(&game), + hash, + ); + if let Some(msg) = game.battle.as_ref().and_then(|b| b.message()) { + println!(" battle: {msg}"); + } else if game.text.active() { + if let Some(page) = game.text.current() { + println!(" text: {}", page.lines.join(" / ")); + } + } + } + } + } + + if stalled > 0 { + println!("\n{stalled} walk(s) stalled — the tape and the map disagree"); + } + println!("ran {total} frames, {} checkpoints", captured.len()); + report_state(&game); + + if let Some(path) = &hashes_path { + if assert_mode { + compare_hashes(path, &captured); + } else { + write_hashes(path, &captured); + } + } +} + +/// Is something on screen waiting for an A press? +/// +/// A battle counts only while it is showing messages: once it asks for an +/// action, pressing A would commit a move, which is a decision the tape should +/// be making explicitly. +fn waiting_on_a(game: &Game) -> bool { + // A running script counts even between boxes: it may be mid-`wait`, or + // about to open the next line, and stopping there would leave the tape + // acting on a world that is still someone else's. + if game.text.active() || game.script.running() { + return true; + } + match game.battle.as_ref() { + Some(b) => !matches!( + b.phase, + spec::phase::CHOOSE_ACTION | spec::phase::CHOOSE_MOVE | spec::phase::CHOOSE_SWITCH + ), + None => false, + } +} + +/// Tick with nothing held until the player is idle. Returns frames spent. +fn settle(game: &mut Game) -> u32 { + let mut spent = 0; + while spent < WALK_CAP && !game.world.player().idle() { + game.tick(0); + spent += 1; + } + // A couple of quiet frames so a landing's consequences (a warp starting, + // an encounter firing) are visible before the next command runs. + for _ in 0..2 { + game.tick(0); + spent += 1; + } + spent +} + +/// The party lives inside the battle while one is running (that ownership is +/// what keeps a fainted lead from looking healthy to the switch menu), so the +/// harness has to ask the right owner. +fn party_size(game: &Game) -> usize { + match game.battle.as_ref() { + Some(b) => b.party.len(), + None => game.player.party.len(), + } +} + +fn mode_name(mode: u8) -> &'static str { + match mode { + spec::mode::OVERWORLD => "overworld", + spec::mode::TEXT => "text", + spec::mode::BATTLE => "battle", + spec::mode::MENU => "menu", + _ => "transition", + } +} + +fn report_state(game: &Game) { + println!( + " map {} at ({}, {}) party {} money {} steps {}", + game.world.map_id, + game.world.player().cx, + game.world.player().cy, + party_size(game), + game.player.money, + game.world.steps, + ); + for (i, m) in game.player.party.mons.iter().enumerate() { + let name = game + .content + .species_of(m.species) + .map(|s| game.content.string(s.name_key)) + .unwrap_or("???"); + println!(" {i}: {name} L{} {}/{} HP", m.level, m.hp, m.max_hp); + } +} + +fn write_hashes(path: &Path, captured: &BTreeMap) { + let mut out = String::from("# pocketmon-sim frame hashes\n"); + for (name, hash) in captured { + out.push_str(&format!("{name} {hash}\n")); + } + if let Err(e) = std::fs::write(path, out) { + eprintln!("cannot write {}: {e}", path.display()); + std::process::exit(1); + } + println!("wrote {}", path.display()); +} + +fn compare_hashes(path: &Path, captured: &BTreeMap) { + let Ok(src) = std::fs::read_to_string(path) else { + eprintln!("cannot read {}", path.display()); + std::process::exit(1); + }; + let mut expected = BTreeMap::new(); + for line in src.lines() { + let line = line.split('#').next().unwrap_or("").trim(); + if line.is_empty() { + continue; + } + let mut parts = line.split_whitespace(); + if let (Some(k), Some(v)) = (parts.next(), parts.next()) { + expected.insert(k.to_string(), v.to_string()); + } + } + let mut failed = 0; + for (name, hash) in captured { + match expected.get(name) { + Some(want) if want == hash => println!(" ok @{name}"), + Some(want) => { + println!(" FAIL @{name}: expected {want}, got {hash}"); + failed += 1; + } + None => { + println!(" FAIL @{name}: no recorded hash"); + failed += 1; + } + } + } + for name in expected.keys() { + if !captured.contains_key(name) { + println!(" FAIL @{name}: checkpoint never reached"); + failed += 1; + } + } + if failed > 0 { + eprintln!("\n{failed} checkpoint(s) differ"); + std::process::exit(1); + } + println!("\nall checkpoints match"); +} diff --git a/engine/pocketmon/crates/pocketmon-sim/src/png.rs b/engine/pocketmon/crates/pocketmon-sim/src/png.rs new file mode 100644 index 00000000..364044ca --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-sim/src/png.rs @@ -0,0 +1,111 @@ +//! A minimal PNG writer. +//! +//! Deflate's "stored" block type lets a valid PNG be written with no +//! compressor at all: the IDAT stream is a zlib header, a run of uncompressed +//! blocks, and an Adler-32. The files are large, but they are debug output for +//! a human to look at, and the alternative is an image dependency in a build +//! that is otherwise hermetic. + +/// CRC-32 (the PNG chunk checksum). +fn crc32(bytes: &[u8]) -> u32 { + let mut table = [0u32; 256]; + for (i, entry) in table.iter_mut().enumerate() { + let mut c = i as u32; + for _ in 0..8 { + c = if c & 1 != 0 { 0xedb8_8320 ^ (c >> 1) } else { c >> 1 }; + } + *entry = c; + } + let mut c = 0xffff_ffffu32; + for &b in bytes { + c = table[((c ^ b as u32) & 0xff) as usize] ^ (c >> 8); + } + c ^ 0xffff_ffff +} + +/// Adler-32 (the zlib stream checksum). +fn adler32(bytes: &[u8]) -> u32 { + let (mut a, mut b) = (1u32, 0u32); + for &byte in bytes { + a = (a + byte as u32) % 65521; + b = (b + a) % 65521; + } + (b << 16) | a +} + +fn chunk(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&(data.len() as u32).to_be_bytes()); + let mut body = Vec::with_capacity(4 + data.len()); + body.extend_from_slice(kind); + body.extend_from_slice(data); + out.extend_from_slice(&body); + out.extend_from_slice(&crc32(&body).to_be_bytes()); +} + +/// Encode an RGBA buffer (`w * h * 4` bytes) as a PNG. +pub fn encode_rgba(w: u32, h: u32, rgba: &[u8]) -> Vec { + assert_eq!(rgba.len(), (w * h * 4) as usize, "rgba buffer size"); + + // Raw scanlines, each prefixed with filter type 0 (None). + let mut raw = Vec::with_capacity(((w * 4 + 1) * h) as usize); + for y in 0..h { + raw.push(0); + let start = (y * w * 4) as usize; + raw.extend_from_slice(&rgba[start..start + (w * 4) as usize]); + } + + // zlib: header, stored blocks of at most 65535 bytes, Adler-32. + let mut z = vec![0x78, 0x01]; + let mut offset = 0; + while offset < raw.len() { + let n = (raw.len() - offset).min(0xffff); + let last = if offset + n >= raw.len() { 1u8 } else { 0u8 }; + z.push(last); + z.extend_from_slice(&(n as u16).to_le_bytes()); + z.extend_from_slice(&(!(n as u16)).to_le_bytes()); + z.extend_from_slice(&raw[offset..offset + n]); + offset += n; + } + z.extend_from_slice(&adler32(&raw).to_be_bytes()); + + let mut out = Vec::with_capacity(z.len() + 128); + out.extend_from_slice(&[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]); + let mut ihdr = Vec::with_capacity(13); + ihdr.extend_from_slice(&w.to_be_bytes()); + ihdr.extend_from_slice(&h.to_be_bytes()); + ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); // 8-bit RGBA, no interlace + chunk(&mut out, b"IHDR", &ihdr); + chunk(&mut out, b"IDAT", &z); + chunk(&mut out, b"IEND", &[]); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_png_has_the_expected_signature_and_chunks() { + let px = vec![0u8; 4 * 4 * 4]; + let png = encode_rgba(4, 4, &px); + assert_eq!(&png[..8], &[0x89, b'P', b'N', b'G', 0x0d, 0x0a, 0x1a, 0x0a]); + assert!(png.windows(4).any(|w| w == b"IHDR")); + assert!(png.windows(4).any(|w| w == b"IDAT")); + assert!(png.windows(4).any(|w| w == b"IEND")); + } + + #[test] + fn checksums_match_known_values() { + // "123456789" is the standard vector for both. + assert_eq!(crc32(b"123456789"), 0xcbf4_3926); + assert_eq!(adler32(b"123456789"), 0x091e_01de); + } + + #[test] + fn a_large_image_spans_multiple_stored_blocks() { + // 480x272 RGBA is ~522 kB of scanlines: more than one 64 kB block. + let px = vec![7u8; 480 * 272 * 4]; + let png = encode_rgba(480, 272, &px); + assert!(png.len() > 480 * 272 * 4); + } +} diff --git a/engine/pocketmon/crates/pocketmon-sim/src/raster.rs b/engine/pocketmon/crates/pocketmon-sim/src/raster.rs new file mode 100644 index 00000000..d7301d10 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-sim/src/raster.rs @@ -0,0 +1,211 @@ +//! Software rasterizer for a [`MonDrawList`]. +//! +//! This is the reference backend: it does exactly what `pocketmon-gu` does on +//! the PSP's GE, in plain Rust, so a golden captured here and a frame captured +//! on hardware describe the same picture. Keeping the two in step is the whole +//! point of the draw list being data rather than immediate-mode calls. + +use pocketmon_core::draw::{DrawCmd, MonDrawList, Quad, Rect}; +use pocketmon_core::spec; +use pocketmon_core::Content; + +/// An RGBA framebuffer. +pub struct Frame { + pub w: u32, + pub h: u32, + pub px: Vec, +} + +impl Frame { + pub fn new(w: u32, h: u32) -> Self { + Frame { w, h, px: vec![0; (w * h * 4) as usize] } + } + + fn blend(&mut self, x: i32, y: i32, r: u8, g: u8, b: u8, a: u8) { + if x < 0 || y < 0 || x >= self.w as i32 || y >= self.h as i32 || a == 0 { + return; + } + let i = ((y as u32 * self.w + x as u32) * 4) as usize; + if a == 255 { + self.px[i] = r; + self.px[i + 1] = g; + self.px[i + 2] = b; + self.px[i + 3] = 255; + return; + } + let av = a as u32; + let inv = 255 - av; + for (k, c) in [r, g, b].into_iter().enumerate() { + self.px[i + k] = ((c as u32 * av + self.px[i + k] as u32 * inv) / 255) as u8; + } + self.px[i + 3] = 255; + } + + /// FNV-1a over the pixels — the golden hash. + pub fn hash(&self) -> u64 { + let mut h = 0xcbf2_9ce4_8422_2325u64; + for &b in &self.px { + h ^= b as u64; + h = h.wrapping_mul(0x1000_0000_01b3); + } + h + } +} + +/// Unpack a u32 ABGR colour (0xAABBGGRR). +fn unpack(c: u32) -> (u8, u8, u8, u8) { + ((c & 0xff) as u8, ((c >> 8) & 0xff) as u8, ((c >> 16) & 0xff) as u8, ((c >> 24) & 0xff) as u8) +} + +/// Rasterize a draw list at an integer scale. +/// +/// `scale` is what turns the 240x136 logical view into the PSP's 480x272 +/// (docs/MON.md §4). Nearest sampling, always: a filtered upscale of pixel art +/// is a different picture, and the goldens would not match hardware. +pub fn render(list: &MonDrawList, content: &Content, scale: u32) -> Frame { + let mut f = Frame::new(spec::VIEW_W as u32 * scale, spec::VIEW_H as u32 * scale); + // Strict push order. Drawing all quads and then all rects would be one + // texture-state switch instead of several, and would paint every panel + // over its own contents. + for item in &list.items { + match item { + DrawCmd::Quad(q) => draw_quad(&mut f, content, q, scale), + DrawCmd::Rect(r) => draw_rect(&mut f, r, scale), + } + } + f +} + +fn draw_rect(f: &mut Frame, r: &Rect, scale: u32) { + let (cr, cg, cb, ca) = unpack(r.color); + for y in 0..r.h as i32 * scale as i32 { + for x in 0..r.w as i32 * scale as i32 { + f.blend(r.x as i32 * scale as i32 + x, r.y as i32 * scale as i32 + y, cr, cg, cb, ca); + } + } +} + +fn draw_quad(f: &mut Frame, content: &Content, q: &Quad, scale: u32) { + let Some(page) = content.pages.get(q.page as usize) else { + return; + }; + let (tr, tg, tb, ta) = unpack(q.tint); + for sy in 0..q.h as u32 { + for sx in 0..q.w as u32 { + // Flips mirror the source read, not the destination write, so a + // flipped sprite lands on exactly the same pixels. + let u = if q.flags & spec::QUAD_FLAG_FLIP_X != 0 { + q.u as u32 + (q.w as u32 - 1 - sx) + } else { + q.u as u32 + sx + }; + let v = if q.flags & spec::QUAD_FLAG_FLIP_Y != 0 { + q.v as u32 + (q.h as u32 - 1 - sy) + } else { + q.v as u32 + sy + }; + if u >= page.w as u32 || v >= page.h as u32 { + continue; + } + let idx = page.pixels[(v * page.w as u32 + u) as usize]; + // Palette index 0 is the transparent key everywhere. + if idx == 0 { + continue; + } + let Some(&colour) = content.palette.get(idx as usize) else { + continue; + }; + let (mut cr, mut cg, mut cb, ca) = unpack(colour); + if q.tint != spec::TINT_NONE { + cr = ((cr as u32 * tr as u32) / 255) as u8; + cg = ((cg as u32 * tg as u32) / 255) as u8; + cb = ((cb as u32 * tb as u32) / 255) as u8; + } + let alpha = if q.tint != spec::TINT_NONE { + ((ca as u32 * ta as u32) / 255) as u8 + } else { + ca + }; + let dx = (q.x as i32 + sx as i32) * scale as i32; + let dy = (q.y as i32 + sy as i32) * scale as i32; + for oy in 0..scale as i32 { + for ox in 0..scale as i32 { + f.blend(dx + ox, dy + oy, cr, cg, cb, alpha); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pocketmon_core::content::AtlasPage; + + fn content_with_page() -> Content { + let mut c = Content::new(); + // A 4x4 page: index 1 everywhere except a transparent top-left pixel. + let mut pixels = vec![1u8; 16]; + pixels[0] = 0; + c.pages.push(AtlasPage { w: 4, h: 4, pixels }); + c.palette[1] = 0xff00_00ff; // opaque red in ABGR + c + } + + #[test] + fn palette_index_zero_is_transparent() { + let c = content_with_page(); + let mut list = MonDrawList::new(); + list.quad(Quad { x: 0, y: 0, u: 0, v: 0, w: 2, h: 2, page: 0, flags: 0, tint: spec::TINT_NONE }); + let f = render(&list, &c, 1); + // (0,0) came from the transparent index; (1,0) did not. + assert_eq!(f.px[3], 0, "alpha at the keyed pixel"); + assert_eq!(f.px[4 + 3], 255); + } + + #[test] + fn scale_duplicates_pixels_exactly() { + let c = content_with_page(); + let mut list = MonDrawList::new(); + list.quad(Quad { x: 0, y: 0, u: 1, v: 1, w: 1, h: 1, page: 0, flags: 0, tint: spec::TINT_NONE }); + let f = render(&list, &c, 2); + for (x, y) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let i = ((y * f.w + x) * 4) as usize; + assert_eq!(f.px[i], 255, "red at {x},{y}"); + } + } + + #[test] + fn a_missing_page_draws_nothing_instead_of_panicking() { + let c = Content::new(); + let mut list = MonDrawList::new(); + list.quad(Quad { x: 0, y: 0, u: 0, v: 0, w: 8, h: 8, page: 3, flags: 0, tint: spec::TINT_NONE }); + let f = render(&list, &c, 1); + assert!(f.px.iter().all(|&b| b == 0)); + } + + #[test] + fn sampling_past_the_page_edge_is_skipped() { + let c = content_with_page(); + let mut list = MonDrawList::new(); + // An 8x8 read out of a 4x4 page: the out-of-range texels are dropped. + list.quad(Quad { x: 0, y: 0, u: 0, v: 0, w: 8, h: 8, page: 0, flags: 0, tint: spec::TINT_NONE }); + let f = render(&list, &c, 1); + let at = |x: u32, y: u32| f.px[((y * f.w + x) * 4 + 3) as usize]; + assert_eq!(at(3, 3), 255); + assert_eq!(at(5, 5), 0, "outside the page contributes nothing"); + } + + #[test] + fn identical_lists_hash_identically() { + let c = content_with_page(); + let mut list = MonDrawList::new(); + list.rect(0, 0, 4, 4, 0xff00_ff00); + let a = render(&list, &c, 1).hash(); + let b = render(&list, &c, 1).hash(); + assert_eq!(a, b); + let mut other = MonDrawList::new(); + other.rect(0, 0, 4, 5, 0xff00_ff00); + assert_ne!(render(&other, &c, 1).hash(), a); + } +} diff --git a/package.json b/package.json index bc1355c7..4613dd7d 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,11 @@ "widget:ipod": "bun tools/widget.ts --stage ipod", "note": "bun tools/note.ts", "psp": "bun tools/psp.ts", + "mon": "bun tools/mon.ts", + "mon:check": "bun tools/mon.ts check", + "mon:shots": "bun tools/mon.ts shots", + "mon:psp": "bun tools/mon.ts psp", + "e2e:mon": "bun tests/e2e/mon-ppsspp.ts", "psp:all": "bun tools/psp-all.ts", "psp:switch": "bun psplink", "vita": "bun tools/vita.ts", @@ -128,7 +133,7 @@ "e2e:launcher": "bun tests/e2e/launcher-ppsspp.ts", "e2e:launcher:vita": "bun tests/e2e/launcher-vita3k.ts", "pocket:pack": "bun tools/pocket-pack.ts", - "test": "bun tools/build.ts hero >/dev/null && bun tests/contract.ts && bun test tests/release-check.test.ts tests/release-notes.test.ts tests/platform-contracts.test.ts tests/pocket-package.test.ts tests/widget-args.test.ts tests/ipod-nano.test.ts tests/note.test.ts tests/site-stage.test.ts tests/host-build-inputs.test.ts tests/platform-runtime.test.ts tests/app-check.test.ts tests/vue-sfc.test.ts tests/font-bake.test.ts tests/touch.test.ts tests/vita-package.test.ts tests/psp-toolchain.test.ts tests/symbian-toolchain.test.ts tests/symbian-device.test.ts tests/symbian-runtime.test.ts tests/cli.test.ts tests/npm-package.test.ts tests/video-outro.test.ts tests/osk-layout.test.ts && bun test --conditions=browser tests/tailwind.test.ts tests/renderer.test.ts tests/cursor.test.ts tests/action-handler-vue-vapor.test.ts tests/vue-vapor-dom.test.ts tests/vue-vapor-pak.test.ts tests/svg-bake.test.ts tests/devtools.test.ts tests/hot.test.ts tests/clock.test.ts tests/tiles.test.ts && bun tools/build.ts hero-vue-sfc-main --framework=vue-vapor >/dev/null && bun tools/build.ts vue-sfc-lab-main --framework=vue-vapor >/dev/null && bun test --conditions=browser tests/vue-sfc-lab.test.ts && bun tools/build.ts cafe-main >/dev/null && bun test --conditions=browser tests/sim.test.ts && bun tools/build.ts zoomlab-main >/dev/null && bun test --conditions=browser tests/deepzoom-sim.test.ts && bun tools/build.ts im-main >/dev/null && bun test --conditions=browser tests/im-sim.test.ts && bun tools/launcher.ts covers >/dev/null && bun test --conditions=browser tests/launcher-sim.test.ts", + "test": "bun tools/build.ts hero >/dev/null && bun tests/contract.ts && bun test tests/mon-contract.test.ts tests/mon-content.test.ts && bun test tests/release-check.test.ts tests/release-notes.test.ts tests/platform-contracts.test.ts tests/pocket-package.test.ts tests/widget-args.test.ts tests/ipod-nano.test.ts tests/note.test.ts tests/site-stage.test.ts tests/host-build-inputs.test.ts tests/platform-runtime.test.ts tests/app-check.test.ts tests/vue-sfc.test.ts tests/font-bake.test.ts tests/touch.test.ts tests/vita-package.test.ts tests/psp-toolchain.test.ts tests/symbian-toolchain.test.ts tests/symbian-device.test.ts tests/symbian-runtime.test.ts tests/cli.test.ts tests/npm-package.test.ts tests/video-outro.test.ts tests/osk-layout.test.ts && bun test --conditions=browser tests/tailwind.test.ts tests/renderer.test.ts tests/cursor.test.ts tests/action-handler-vue-vapor.test.ts tests/vue-vapor-dom.test.ts tests/vue-vapor-pak.test.ts tests/svg-bake.test.ts tests/devtools.test.ts tests/hot.test.ts tests/clock.test.ts tests/tiles.test.ts && bun tools/build.ts hero-vue-sfc-main --framework=vue-vapor >/dev/null && bun tools/build.ts vue-sfc-lab-main --framework=vue-vapor >/dev/null && bun test --conditions=browser tests/vue-sfc-lab.test.ts && bun tools/build.ts cafe-main >/dev/null && bun test --conditions=browser tests/sim.test.ts && bun tools/build.ts zoomlab-main >/dev/null && bun test --conditions=browser tests/deepzoom-sim.test.ts && bun tools/build.ts im-main >/dev/null && bun test --conditions=browser tests/im-sim.test.ts && bun tools/launcher.ts covers >/dev/null && bun test --conditions=browser tests/launcher-sim.test.ts", "tape": "bun tools/tape.ts", "tape:check": "bun tools/tape.ts replay hero-main tests/tapes/hero-main.tape.json --assert tests/tapes/hero-main.hashes.json", "devtools": "bun tools/devtools.ts", diff --git a/tests/e2e/mon-ppsspp.ts b/tests/e2e/mon-ppsspp.ts new file mode 100644 index 00000000..408239f4 --- /dev/null +++ b/tests/e2e/mon-ppsspp.ts @@ -0,0 +1,163 @@ +// tests/e2e/mon-ppsspp.ts — the Pocket Mon EBOOT under PPSSPPHeadless. +// +// bun tests/e2e/mon-ppsspp.ts compare against tests/goldens/mon/psp/ +// UPDATE=1 bun tests/e2e/mon-ppsspp.ts regenerate them (then LOOK at the PNGs) +// +// This is the test that makes "it runs on a PSP" a fact rather than a claim. +// The sim goldens (tests/goldens/mon/story.hashes) prove the *rules* are +// stable; these prove the console actually boots the thing, parses the +// embedded content, and puts the same pixels on screen through the GE that +// the software rasterizer produces on a laptop. +// +// Determinism: the capture build ignores the pad and replays a baked input +// tape indexed by frame number, and the core ticks a fixed step, so every +// frame is a pure function of its index. PPSSPP's software renderer is the +// only byte-stable backend, hence `--graphics=software`. +// +// Host deps: PPSSPPHeadless (source build) and ImageMagick. + +import { $ } from "bun"; +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { homedir } from "node:os"; + +const root = new URL("../..", import.meta.url).pathname; +const goldensDir = `${root}tests/goldens/mon/psp`; +const outDir = `${root}dist/e2e-mon`; +const headless = process.env.PPSSPP_HEADLESS || `${homedir()}/ppsspp-src/build/PPSSPPHeadless`; +// PPSSPPHeadless maps ms0: to ~/.ppsspp — dumps land in ~/.ppsspp/mon_cap. +// Contents persist across runs; always clean first. +const capDir = `${homedir()}/.ppsspp/mon_cap`; +const eboot = `${root}engine/pocketmon/crates/pocketmon-psp/target/mipsel-sony-psp/debug/EBOOT.PBP`; +const update = process.env.UPDATE === "1"; + +// --------------------------------------------------------------------------- +// The run. +// +// Button masks are `spec::btn` values from contracts/spec/mon-spec.ts: +// up 1 down 2 left 4 right 8 a 16 b 32 start 64 select 128 +// +// The tape below walks the player out of the house the same way the sim tape +// does — down four, right four, onto the doormat — so the two harnesses are +// describing the same journey through the same content. +// --------------------------------------------------------------------------- +const INPUT = [ + "0:0", // a beat on the bedroom floor + "20:2", // hold DOWN: four steps south + "110:0", + "130:8", // hold RIGHT: four steps east, onto the mat + "220:0", // the warp fade runs on its own +].join(","); + +const CAP_START = 0; +const CAP_N = 300; + +interface Shot { + name: string; + frame: number; +} + +const SHOTS: Shot[] = [ + { name: "boot", frame: 8 }, // the bedroom, content parsed and drawn + { name: "walking", frame: 60 }, // mid-stride, camera following + { name: "doormat", frame: 215 }, // standing on the mat, warp starting + { name: "village", frame: 290 }, // outside, on the other side of the fade +]; + +// --------------------------------------------------------------------------- + +if (!existsSync(headless)) { + console.error(`PPSSPPHeadless not found at ${headless} (set PPSSPP_HEADLESS)`); + process.exit(2); +} +if (!Bun.which("magick")) { + console.error("ImageMagick `magick` not found (brew install imagemagick)"); + process.exit(2); +} + +rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); +mkdirSync(goldensDir, { recursive: true }); + +console.log("# build the capture EBOOT ..."); +await $`bun tools/mon.ts psp --features capture` + .cwd(root) + .env({ + ...process.env, + MON_CAPTURE_INPUT: INPUT, + MON_CAP_START: String(CAP_START), + MON_CAP_N: String(CAP_N), + }) + .quiet(); + +console.log("# PPSSPPHeadless (software renderer) ..."); +rmSync(capDir, { recursive: true, force: true }); +const timeout = Number(process.env.E2E_TIMEOUT || 90); +const run = await $`${headless} --graphics=software --timeout=${timeout} ${eboot}` + .cwd("/tmp") + .nothrow() + .quiet(); + +// Liveness: did the loop present every frame of the window? This alone catches +// a boot hang, a content-parse failure (the EBOOT halts) and a wedged frame +// loop — the three ways "runs on hardware" usually fails. +const produced = existsSync(capDir) + ? readdirSync(capDir).filter((f) => /^f\d{4}\.raw$/.test(f)).length + : 0; +if (produced !== CAP_N) { + console.error( + `FAIL: produced ${produced}/${CAP_N} capture frames within ${timeout}s.\n` + + `PPSSPP output:\n${run.stdout}${run.stderr}`, + ); + process.exit(1); +} +console.log(`liveness: ${produced}/${CAP_N} frames presented`); + +let failed = false; +for (const shot of SHOTS) { + if (shot.frame < CAP_START || shot.frame >= CAP_START + CAP_N) { + throw new Error(`${shot.name}: frame ${shot.frame} is outside the capture window`); + } + const idx = String(shot.frame - CAP_START).padStart(4, "0"); + const raw = `${capDir}/f${idx}.raw`; + + // Refuse a flat frame even when regenerating: a golden that is all one + // colour records nothing, and would happily "pass" forever. + const buf = readFileSync(raw); + const pixels = new Uint32Array(buf.buffer, buf.byteOffset, buf.length / 4); + const distinct = new Set(); + outer: for (let y = 0; y < 272; y++) { + for (let x = 0; x < 480; x++) { + distinct.add(pixels[y * 512 + x]!); + if (distinct.size >= 8) break outer; + } + } + if (distinct.size < 8) { + console.error(`FAIL ${shot.name}: frame ${shot.frame} is flat (${distinct.size} colours)`); + failed = true; + continue; + } + + // The dumps are 512-stride RGBA, top-down; crop to the visible panel. + const png = `${outDir}/${shot.name}.png`; + await $`magick -size 512x272 -depth 8 RGBA:${raw} -alpha off -crop 480x272+0+0 +repage -depth 8 -define png:exclude-chunks=date,time PNG24:${png}`.quiet(); + + const golden = `${goldensDir}/${shot.name}.png`; + if (update || !existsSync(golden)) { + await $`cp ${png} ${golden}`.quiet(); + console.log(` wrote ${shot.name}.png (${distinct.size}+ colours)`); + continue; + } + const same = Buffer.from(readFileSync(png)).equals(Buffer.from(readFileSync(golden))); + if (same) { + console.log(` ok ${shot.name}`); + } else { + console.error(` FAIL ${shot.name}: differs from the golden (${png} vs ${golden})`); + failed = true; + } +} + +if (failed) { + console.error("\nmon e2e: FAILED"); + process.exit(1); +} +console.log(`\nmon e2e: all ${SHOTS.length} shots ${update ? "recorded" : "match"}`); diff --git a/tests/goldens/mon/psp/boot.png b/tests/goldens/mon/psp/boot.png new file mode 100644 index 0000000000000000000000000000000000000000..a054a3b0d14eaa1af8f030d6de78b31f9590dfcf GIT binary patch literal 2784 zcmeHJZBSEJ8ord-#Bm!_cE^fA5)0jqU3H@@%3|cwi?Dn+lA3i8wFX2%5Fsr}Fpvax zFRiw$(2>t7AJOvRiV!6!hyv+I1tmh*P=gRdR+d1T2&p6?-XtV@Z?b6Z4uAI7{@A&5 z=gyq_o^#&!d7tMw=kpI^BMB=>D**r?L`Q`)0KnrP==;`+H_(xpceg0f&-$ZLj6DF5 z`yK!k6as)19V*ZQK=u{@&?N%Ejw%3H^Ktd)g0I)JGIvm<} zW_(ul&bh;5zPKMn4Ss$}_ZWVkF}5ac@bZ^3wq8s6{F?XOUv*w7WBB>S9PskG;um%x zCSs~>lUIY^^{b@473E&L)_MgeMSSSWE;*oJ6oj=}OLjajO>xz1s? z%yuS#x4!8}0PY(_iUgoQx^@`2{AOywhq%BGHh7ZOmw6E08YsZ6NdCaHFhJlD_TT+B z8x;wOqrMbhv18@8+d@Kvo2^_QbOWE$h4hlp&H&>hBWp^d@idwrqkD`A;ObhYBTq4_ z#Lv&qKSf9KWk$>1hNlRCk{RKB< zJ6hs)?fSIEc68=HjoYw&m#IxdKK-Sbe~80D+Oa%LP*5Y0WNjdl5+j#pWPTDJrSB-O ztfbTl`*A2@INx_$)NxEEpWB1O3S9=tC-ABZ3f4v{Rb4?qu?Dq$CTN$$1bIGtIb(D2 zilro%z^OMY2c*&x*=QoXd}H#4A-Od}qtPhaAkRWMUR4FAYAeCi0$)u~Y6_z2=Q(}8 z&6k9vEv`;M%>9uqlDnInj`lg+IPKq^5%ZOQLpq#XPO_uVKs;*fw>Y`U4rD}fZ#u7% zU2BFsKmLUe1`Qgv2zT!e&Cg~}MD*m?@c4$Z{l|~yKiVU0syWUZ2HtFgL@6>lv&W|H zk36f=*tYTa4?Ywc~CL;2)Na;#C!(_7O%q zJI?o=7BxGTVuD3R#z2j7q*#&U&(FG?pLtcyd2XlXXP=TA4J};$L5T>|CO?>wkll^M zI+VTu$!_tw&fHzSIP|DieMf8o%#iFj#vi!xN@Sf{5@1BWZfcQCS7`O!P@lVykp@YZ zqo-_sa;$e_X%2YkSe(ilrO%mY^dUVw;+}iE_YOVgeX4|iW3n_3&9|odRfiUGdBa5= zW^#3HC4*ef_?_|?l|Mge>d(nPVJ2wmb~?kd%to2|$vVV7Tgs;IqL}u%Wr;C9T?_NM z%$Q)Fwbxv?WfGAr_CNk(aYn|uSbdHH-_67#tpXoq52%fxswySON%^Tcp5^*q(?4bI zbWZ=hH(Hi|YW_$0j2_P1O);Io;@uslH4$@*i)3qcaO-osn2!=vdO?=|6GyNLY!?>D z0#J)!z@Hr6_leDMHu*`2HlppL!<(M}+KKdD3Ya6C{5WUi*19MjuRG|zx0}A#yq&9_ zzL>h(M48y&G&I{%4U57*3uS6U`kK(tuDpdgIwAUX0Dq4vqM=OaFsrsXz7OCHKj64G z#ndYt#fS7)fm#xMA}-AmXoBd;F4QreqE%Rnvf^VpEiVqdujxBaQS%%Nv$o!LJE9}s zxeRK9gmfy`=&2OuooXK*YxDL{b5?Jyp>tv?!)V<~NDZs8^@KZ^uLb5N~hda**_?t*DQ7 z-t$7;kL}>e#CdPbKUkVS{*0vW9-OO`+&XZ<`l-e?kS;nj=+cua-ryq!Q&IT{Q_uo6 zZ$}oQoUH~wVngGcph`M7!Fw)4RsdXv}G*UVHKSuWHu ztA1^vEWyss&BZ^@yMD0s=7jQ@36kpJR<~OI55WHh1Lk(mp3l~C{)2}n zqG-glncx|_(tSNaC8L8U=D6b=^icUJ7kk>ph7o_vcYnslc6kpS#KsP-!<21?SUKSA z&U*LpC{G!UH)OC;-x(OqEV-0R7LT=HLP&-)uUiNQDW?-mkg$?~UN}%`p{Kz1CSoON zVZgFKWk}kRKGPIKlLgPHT~c!0J18dhj0C-}FzpYXwtO@7(&;h1l*GQw@v76KGc6z_ UW-~1cg?2RuUg+$rP1bA{Yt3Edm`|XmC0;N$JgZAl3bR1^q7M~*>|6+s;zP+G;ZSpUD>yVUil z1vmeA95le%^7SiM-|*XJcV)bG{MD5K8#m$a&7Z$z&sas;QbbsHZc(eEE2b|xwTX8| z-BU;Z=6Y3Z})aIx-@r`f49eF zdaONfiWg-F*B~~&iI55~VGTna1-rVL+IpfTu;ZQZaMVd5?nAtknf)(S47Jf1m~~{Q z+(#)i&7jsvO|6O6(U8psgW-@7sdxSQ+1P;beo_xZ5$hMo!MZf%)U9k{%E&zL*B;!N z*Y)V$I6heQNaT`GUM(!Nq*%oB7}V6vTL+F3K9O=e7c6=E4w@(Q3P2*j&gOwhGOTXTLIy?b$B&>ZKAr&mwdITQI#Nr z&Q9Z#C8*EL1uOY^UR^n7d^}ew5@huSi|fTIRn&ic$%ZJe>)#~E*v&4%lbSY*UHs(q z(g3!yFb)M`1&+b72*Wgj1~(Nd>_9LM+)onxZpY;0jy*wWJBqC84D+e3jlc&D^)D9j z5iw$SM`V?qzJOO1U2jNcD&uABND}=+;!#4nt3*9pU^H#t@k2v@?)?Ugs}h@4A~Sfa zO?XZ;>8RKI1|$!l6}e+QEj=m`ab%~J6$0*w!f!sqjC_8N^`ELd!+wXv%Bp!9RZFYR zIF)fd^aLG{(F12cBH`LJo>TvlJR|;*JDTsmg~*I|BL@PI)^m_W!|*KME0I+ht*lS4 z`cLx;F%Xzz8k&PlDUAc8k4c{)*B(2wZa@T_{S{nYbNaj{uT8bRQf<&m&H z{bG6h`o)=U08&ug7f2yTh~JQ~DD#*#n!_%R*aAi3fr+}*R0MX#qVT>ZH#zBL*?Ltqx~ z2~R})&q5tRkEzvD%n4W)+_({ChtRYZk+W`J!7Bha@a9M9Opg6jr{f%Q2`kRSH94SQ zn2ngC0Q7sa53&5DGW7I!a&?GsB}n;%ZDqac;wnxPn@F;79pAN5J%ddK2fJkliagxu zi0~W+E>^RZ9DMN@_+K{o}^P)UNg^}`t(@|WVBmkcE5 zg<8c=igER=<<|y?l4X$;=_m)~u;d1-l4GOxIfK_ZAC7GeR*g0x1F@SWMKjeImr&!K zYJpkp?7t3c?B0L(FBPn%+a%6Zi>OEH;zvcgCM_KtWk;S9Z5u`i*PBgE6)x^w8_Bld zIyP~rD8E|R>{w%U6)0_aNUev$n0JM#foLTUo}dy_B7;JjkeFSmC4yvqI48Y2snj8= z2#K4p>FX+tS(epgd!i6u5hp`4T>@x^KgUCdV^CB z9?^B8X)7eR^QHTz@hd5;v{yW_7~faeR|i!K7~r5_syX*D$6+ZPQgb6YT3gF z^4QW7!!y^M1|v@D=@e)$7>*@dF&QRm6zjT+Q5m%q$C&X_vpB5Kkt&rB;%dHB+F(#% zt)q}iWFj$>QPw>U1nZD#4L>lzm>zQYT9L#5nx_)?Quj_|&C&_nj1ZK=``L$dxLkqFWb-$pP_Ch|`fttycz z#%pSwUA_IS?e@P9I_q3cD{Bt#9AGf{bcvvzz}L!M=9wyzpkro6PRx)a7KV072=sItR>FSeLu%SWUL_sB!ahkyMHd(g0BM44J;N(cA2# zo|})#>x4D-9Lwmunv1A5al3#z)8%wK23?6EE%vwRD5d07#u?nNBQwgAZ00*{5`S`< zgf$1#fONAi6wnpWT{3m9P9F@!=Ik-Y#cXVLjBmfvGs^Sa(h;fx3rK(u-=Qi7Z z+xLc;A)2nx;n{CsQB^AAH?kvxn!|C#qpj4zH0ta^iJ{|Lao|r-o|m^Q*dwc{dSiAB z2g%C;Yj0x{yuh`zeGA-D<)s0#^(0=ze@ENw5)XHT;IUD9?F1!-m#r$4F5%Hr!@%x^y6C3a39@M7HZcUJ^= zM1tS!#Azw9;KR6c(24zBV!KqqKERWd0*8_Q<4 zx7&aE$Cc7Q)-l`8EYLn%n2|d)uyD>M`_Y6|_8XtaRL6TUzw(-Yq^<9oKY158$mT!kCR) z4(*ADZ5TG|nUyN%j;KSznRJgca95O3Xc?PbftD2#)>p%|Za)bxN?>WyOQ}ySgI4z^ zRGnp;fG&P9IQc$oyhNgk4*=)a0$Orxi+EMcDv^jdt|-Mo&9xpYa%HiH-M}K;24+_% z^yC6637g%`PrOoyx_CX!D}UDEO#c9?F0?qFnpO_KeC{VdxRKc3dI2yuG&~&F4Nh#& zyfm~>7opixx+g50Sx0gg=Mk^DPmNpp7T~1q&ZjO1Sh*Ucc-B+vM;9&mTh*`Q&zX+TotgyXIc57#_l0=kEgFxS+VLge@(b_8$E| D&*oSE literal 0 HcmV?d00001 diff --git a/tests/goldens/mon/psp/village.png b/tests/goldens/mon/psp/village.png new file mode 100644 index 0000000000000000000000000000000000000000..38e684f31258c2ac7f2a4cd8f8b11783434797bc GIT binary patch literal 3538 zcmdT{i(k^`8mBWSbIaJtosY{>vYXYGw&@~uKr~wyZ>?IEByM>odR$@w8Oo)JXSU;x z-Mr)#S~=TkY7R?Nfwh)8QDIqnydY^JDk_bO><0+v{QV!Q9A`ys;4G6^9Yy`pt zZk?S#An02Wh^dbdh>$A?glAe=(+BT@2d>A%cZVTt@P{v~x(uQc_l6M;of()Jdh3S# zu{ZMB&abw<^UF-6fp4V<%v&Q@F?X-!s1WTLQYBP#8w%JgH4$qT*Wz{LiMNfU9le2df zuQUv2e3O5Qrcs}|l!9UZI-yW1l^$9#{tSCFHBAVfoU#2FbF;wqbfoPAb#bBX-WS1$ z|1>_+hxdhVl3J(vU)t`9Ab~|7M&mNp1O)Ida5T4L!X+e@Q7Ej5Rx><;cHGGE(6^3D z4Kw)3oaoQTluGruDniCnguYq%F7@e?AIBgS#)HwKgvxT>=U8o^Fv8zLm7{3Sc%{!2UgX#_{`Noh+9Q*NPo z?iNLQwpn=TH*#9syjDMY@x*DZc4j=sgQmr}wj~KlyX~vKop(JesKefjQrzAn5p=n@ zzq$`y-@?2P(V3UGlCeG74M}JBoA^rQ%5F86ls>%p0)r(}+e1upYsxqy%9Srhspk$# zAG2S{9fGu%2PnQJVB37$LHg@`*=nEENoH1X$P2KYHA>G{okg z6F8CVNcRmD#>n?bJ=W21CBAJ-UYQdkIMu!f_m)#POk@vwB{0aco(K>`Pl)N37W0XB z1)DflsLQ3CLrmx_a|>b28_VGaJVeF?YnGO*?>(8zeao?dfgr(@^&y3`Ft%`!jJQ>A zjx5cP!}03-KLlVE#$Q@|!L8}k@QR@D2`&k*I70Y5D?t;5ApgZ?*o zL2zxj7DT)#SnPs{pL_*3K@YT7U5$zrjkJJEJJc{rq1?nT05?DS(q45E2;dI3sSSk* zT#0HrMe;<3Z+8}X;&g$|UgW|HpJdgP!8MrpeGZ<1lyOPWdYJ9)N2T=+C_&Jm2(-Sb z7;t)$+l$=O^cGchz=N zQZ6gr5pRJ~$7R$QUK#Xq@v?LaBiB+qKEs=|pQQ%_l(SE}Y|R*7p6(mdWJOgqnF7_% zUl4E>=+rQb6y`;bP^eLA% z`J2g(GXyZn)tZ=qFf(YD3=s(vvKH0k&yvym@lFcw9&1>yi1U7^dPBU9VvBILEa#yr zvqIVf(qIm-+xzhgC>jWTpkJxOj@n_i>DM&9cI1cOuIq6MZ}xETm`dfW+J2OKrF@>{ zOoQd7xaIait5D3QP3y#Rp}0qF8GWE`!`Y^w`D%B+%%=nW(>a3g)K;cBNvvrDQ)6lb z<5p$PZA)F-&ijzPR&{!+1`4LOrOg!wBHiPuziMiS*v7L*c{FbzXiCI~MOUB-l{lIu z3x=}isfTJROa^0v6A`X9x#6HujufU?uSH|1veas?LGd-*M5l!@!_zd=byIcHbQTL# z=>0R&`=X+dnf}f_JSlVkKE{ara@Ay{c3dte;eQ8cL9etl(7fsxW4~zTW~O!^z3Q`g zZ&v#8?M$|=AhR+bKV(gQWNPoDLwx~-2Yf8hYy~Oa(F`!Kk>&RG@Wg`has%N7+HN#0 z3k(i3C@>Nf?5Vp0l&dM}BFptT;*slyd4a}Y7v1fIXrm{kQxjUq5M9mo=rStBZW*+w>U4ou2}v@r=;}$w$@XbJbMW zxT3(Mvr&`Wymm^ss5xX@mzh(UPflIc$&7>qU8wcbIdQsVb@45$gPHGD8g$)1QKViy zyaK==_QsF#(57C;f=F*F^MEqD!#(<36U^OjfAvej>uNt;~8H5_A}4 z4=&Cnn@hCVA?_?=9+pi#0q8=$B4S5%kl(ifcygA2GZ4pFkS1&yL4XHeepFX?*(R3E z#P?JZHM$d6FmqB#=%5`{uokGfiEd;WJs2PkM#DMFow4Sd375pbJbv#FxjsaCZ@$V) z?_fYN)}vQp_Y!syD3l@Nig0##dXd6CYL=*4mDz}t>rAjofn`8W=TX!^ zvFJckmW04sBgzR(nyUhu|MzbD9WcVaV%k>%=6AtFueyb=g&6DSBy3!dgB>&xJw@5Mi8;yb2N-d<~K4C>E- z-N$?A;X=R8o#DVno2comPf-j`z~!q(^7$;47H{uCRXvS7iU1JPgQa8J*1sM~?4>$++0V1KRa^R60vvl#&rArs@rYp(W1PVEotYl44i$${=JZ{5RH4y*3JC_*eY0F<+XDMuYbbPPN4>yzM5o6er~$-UEWN$j4d>qDKXKamjH4D?d7P zn^B?2AgE@1Gnp3zbe9D61+LY0yad+^X0~=y+L9`LMU38&bU_cViolQn8$EEr8t@QH zHg%N0^Q_)LGk}ey$ByUZb(RT?yJS@5?w*J1%^NYVcm4R+nX~IbF0U3zi(_85Z$J0W zjd)VPjAuynW4QBcbb(s7?O{7R|ByIQJx0?~gGSsywqW6s#qGUdl>sOH`~!sC>25i3 z2OBesp$}1yq}`yxSHxm?&E~LH-c@AF2Karq74ZiG~jYE zmtA9!Hw+*YgX6h+Cy*;G8F%GkG$u<~D`{f}cCB|3miBxIZNPa&N%Pos+o8=@;dUGn z>3tOvF46)|b8e*iQdA-)Af1{*0=Wjp_70it5XbcHM!(JXOLpw4g33v%`)k8Bcefll~ShvJn2O1)rkYEd5BX#+)QZB?AVg7-82h@yPxLSl z^8H%j)@gtj;bD<--V( z=*+6f$_;`z5)XBxIM-CEEA?IwCl_HEyFR?hA7FlZQ$?&UlMHgjTJ~~FX8cbS zK)Z?2O!B<^#aQE;aSu)jc@Z(dOh>${S!;PB zY1Ru2-?JV?m8$w+|1G)5UA9}6m(k2JB~%cq;e`AyRK(KnSogZKuPAIyHtWgH#8!iq zow@T%d{)YKC;j?F(R96)pyMA^>93&3%gs!W!u?Mj$N5ECcaw8}$S1#Jc{lM7-B*af z?*;VvDnhL#>}teWN1BBL;r$4Ohh}=PC=B~bt}lAcir;#1@&Y9@u_6HmWrMyW`)o`# z$&KmhK5MiIce1k>YtuQiptmvpKE37Oq-m(*&%wOVfSzsYB-p!%Pr1ZP98ybqu1*gM zv%}VjruvHANF+2x$k6|6RySU<|GRsqo|N`8j_EsuC$c@U|85?T&M?WXrN@XEgZi71 z*${G>d@&vKk;I#f{2he76jrKLLF8+%vKvu~wY zn{WWw&P&00UlTCD)c1u>1{y!MU7G@O-HJc7ECNML&?gHYksp3yzl=Dj5Z``MOB z5eg10Z*xoq6PQQSIlNt#pjtc0L5;GQfyzMb9XRyL7uG^tihhz~IsEt4S}}jZ`1=fp zCW~z9moXd#o&~9XcVzP6F&q?ENLs9H7$8HZ1L#m<2K{m9z(=0nL-GIb@l%miak4L` UJe=|XI=KS)oe@C|+YcB12atR@p8x;= literal 0 HcmV?d00001 diff --git a/tests/goldens/mon/story.hashes b/tests/goldens/mon/story.hashes new file mode 100644 index 00000000..12ff8185 --- /dev/null +++ b/tests/goldens/mon/story.hashes @@ -0,0 +1,15 @@ +# pocketmon-sim frame hashes +00-wake 81fa2eb78e13d295 +01-village 4aa5c32ec30f1b55 +02-inside-lab f78678a045e60a5d +03-facing-professor 41c85940b3879e85 +04-partner-in-hand 41c85940b3879e85 +06-back-outside 49606139f750e365 +07-route-one 79b314f6139df8b5 +08-in-the-grass 34f89a2914228005 +09-wild-appears 6c3c7da197d7a4f5 +10-action-menu aea75d34e0e13805 +11-move-menu 1b442aa8b93776f5 +12-battle-over 19e303caa29558c5 +13-second-encounter ddf25c14fc24837d +14-still-standing 3853825d77851405 diff --git a/tests/mon-content.test.ts b/tests/mon-content.test.ts new file mode 100644 index 00000000..f16519bc --- /dev/null +++ b/tests/mon-content.test.ts @@ -0,0 +1,460 @@ +// SPARKWOOD content integrity (docs/MON.md). +// +// The cooker turns TypeScript into a binary the Rust core reads in one linear +// pass with no validation beyond bounds checks. Anything wrong with the +// content therefore shows up on a PSP as a creature with no moves or a door +// that goes nowhere — a long way from the edit that caused it. These tests +// close that distance. +// +// The first test is the one that matters most: the runtime is a clean-room +// port, and nothing in it may grow a path that reads a ROM. + +import { expect, test } from "bun:test"; +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; + +import { assembleScript, cook } from "../apps/mon/cook.ts"; +import { CAST } from "../apps/mon/art/actors.ts"; +import { characters, FONT, GLYPH_H, GLYPH_W } from "../apps/mon/art/font.ts"; +import { BLOCK_TILES, TILE_ART, TILE_BEHAVIOR } from "../apps/mon/art/tiles.ts"; +import { + blocksOf, + buildText, + ITEMS, + MAPS, + MATCHUPS, + MOVES, + SCRIPTS, + SPECIES, + TRAINERS, + TYPE_NAMES, + VERB, +} from "../apps/mon/content/game.ts"; +import { MONPAK_HEADER_SIZE, MONPAK_MAGIC, SLOT_COUNT } from "../contracts/spec/mon-spec.ts"; +import { TextTable } from "../apps/mon/content/text.ts"; + +const root = new URL("..", import.meta.url).pathname; + +// --------------------------------------------------------------------------- +// The clean-room boundary +// --------------------------------------------------------------------------- + +function walk(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + const path = join(dir, entry); + if (statSync(path).isDirectory()) walk(path, out); + else out.push(path); + } + return out; +} + +test("the runtime has no path that reads a ROM", () => { + // docs/MON.md §1: the upstream project reconstructs its content by decoding + // a Game Boy ROM. This port has no counterpart to that layer and must never + // grow one — the whole legal and design premise rests on it. + const sources = [ + ...walk(join(root, "apps/mon")), + ...walk(join(root, "engine/pocketmon")), + ].filter((p) => /\.(ts|rs)$/.test(p) && !p.includes("/target/")); + + expect(sources.length).toBeGreaterThan(10); + + // Patterns that would indicate a ROM path. Prose in comments is allowed — + // the docs discuss the boundary — so this looks for code shapes. + const forbidden: Array<[RegExp, string]> = [ + [/\.gb['"`]/, "a .gb file extension"], + [/\.gbc['"`]/, "a .gbc file extension"], + [/sha1|sha-1/i, "a SHA-1 check (the upstream ROM gate)"], + [/\bpokered\b/i, "a reference to a disassembly source tree"], + ]; + const findings: string[] = []; + for (const path of sources) { + const text = readFileSync(path, "utf8"); + for (const [pattern, what] of forbidden) { + if (pattern.test(text)) findings.push(`${path.replace(root, "")}: ${what}`); + } + } + expect(findings).toEqual([]); +}); + +// --------------------------------------------------------------------------- +// Determinism +// --------------------------------------------------------------------------- + +test("cooking twice produces identical bytes", () => { + // The PSP goldens hash a pak built on whatever machine ran CI. Procedural + // art plus an interning text table gives plenty of room for iteration order + // to leak in; this is the guard. + const a = cook(); + const b = cook(); + expect(a.length).toBe(b.length); + expect(Buffer.from(a).equals(Buffer.from(b))).toBe(true); +}); + +test("the pak has a well-formed header and section table", () => { + const pak = cook(); + const dv = new DataView(pak.buffer, pak.byteOffset, pak.byteLength); + expect(dv.getUint32(0, true)).toBe(MONPAK_MAGIC); + const sections = dv.getUint16(6, true); + expect(sections).toBeGreaterThan(5); + expect(dv.getUint32(8, true)).toBe(pak.length); + + // Every section must lie inside the blob and not overlap the table. + const tableEnd = MONPAK_HEADER_SIZE + sections * 16; + for (let i = 0; i < sections; i++) { + const at = MONPAK_HEADER_SIZE + i * 16; + const offset = dv.getUint32(at + 4, true); + const length = dv.getUint32(at + 8, true); + expect(offset).toBeGreaterThanOrEqual(tableEnd); + expect(offset + length).toBeLessThanOrEqual(pak.length); + } +}); + +// --------------------------------------------------------------------------- +// Referential integrity +// --------------------------------------------------------------------------- + +test("every learnset move exists", () => { + const known = new Set(MOVES.map((m) => m.id)); + for (const s of SPECIES) { + for (const [level, move] of s.learnset) { + expect(known.has(move)).toBe(true); + expect(level).toBeGreaterThan(0); + expect(level).toBeLessThanOrEqual(100); + } + } +}); + +test("every species can act at level five", () => { + // A starter with no level-1 move would stand there doing nothing, which is + // the least debuggable possible first impression. + for (const s of SPECIES) { + const early = s.learnset.filter(([level]) => level <= 5); + expect(early.length).toBeGreaterThan(0); + } +}); + +test("evolutions point at real species and go forward", () => { + const byId = new Map(SPECIES.map((s) => [s.id, s])); + for (const s of SPECIES) { + if (s.evolveInto === undefined) continue; + const into = byId.get(s.evolveInto); + expect(into).toBeDefined(); + expect(s.evolveLevel).toBeGreaterThan(1); + // An evolution that is not an improvement is a content bug, not a design. + const before = s.hp + s.atk + s.def + s.spd + s.spc; + const after = into!.hp + into!.atk + into!.def + into!.spd + into!.spc; + expect(after).toBeGreaterThan(before); + } +}); + +test("no species evolves into itself, directly or in a cycle", () => { + const byId = new Map(SPECIES.map((s) => [s.id, s])); + for (const start of SPECIES) { + const seen = new Set([start.id]); + let cur = start; + while (cur.evolveInto !== undefined) { + expect(seen.has(cur.evolveInto)).toBe(false); + seen.add(cur.evolveInto); + const next = byId.get(cur.evolveInto); + if (!next) break; + cur = next; + } + } +}); + +test("every type matchup names a real type", () => { + for (const [atk, def, mult] of MATCHUPS) { + expect(atk).toBeLessThan(TYPE_NAMES.length); + expect(def).toBeLessThan(TYPE_NAMES.length); + // x10 fixed point: 0 (immune), 5 (resisted), 20 (super effective). + expect([0, 5, 20]).toContain(mult); + } +}); + +test("the type chart is not one-sided", () => { + // Every type should be able to hit something hard and take something badly, + // or it is either useless or an auto-pick. + for (let t = 0; t < TYPE_NAMES.length; t++) { + const strongAgainst = MATCHUPS.some(([a, , m]) => a === t && m > 10); + const weakTo = MATCHUPS.some(([, d, m]) => d === t && m > 10); + if (TYPE_NAMES[t] === "NORMAL") continue; // deliberately plain + expect(strongAgainst || weakTo).toBe(true); + } +}); + +test("every move a trainer fields is real and learnable-shaped", () => { + const moves = new Set(MOVES.map((m) => m.id)); + const species = new Set(SPECIES.map((s) => s.id)); + for (const t of TRAINERS) { + expect(t.party.length).toBeGreaterThan(0); + for (const p of t.party) { + expect(species.has(p.species)).toBe(true); + expect(p.level).toBeGreaterThan(0); + const real = p.moves.filter((m) => m !== 0); + expect(real.length).toBeGreaterThan(0); + for (const m of real) expect(moves.has(m)).toBe(true); + } + } +}); + +test("every encounter slot names a real species", () => { + const species = new Set(SPECIES.map((s) => s.id)); + for (const m of MAPS) { + if (!m.slots) continue; + expect(m.slots.length).toBe(SLOT_COUNT); + for (const [id, level] of m.slots) { + expect(species.has(id)).toBe(true); + expect(level).toBeGreaterThan(0); + } + } +}); + +test("a map with encounter slots has a non-zero rate, and vice versa", () => { + for (const m of MAPS) { + const hasSlots = (m.slots?.length ?? 0) > 0; + const hasRate = (m.encounterRate ?? 0) > 0; + expect(hasSlots).toBe(hasRate); + } +}); + +// --------------------------------------------------------------------------- +// The world +// --------------------------------------------------------------------------- + +test("every map is rectangular and uses known blocks", () => { + for (const m of MAPS) { + const widths = new Set(m.rows.map((r) => r.length)); + expect(widths.size).toBe(1); + const { w, h, blocks } = blocksOf(m); + expect(blocks.length).toBe(w * h); + for (const b of blocks) expect(BLOCK_TILES[b]).toBeDefined(); + } +}); + +test("every warp lands on a real map and a real warp index", () => { + const byId = new Map(MAPS.map((m) => [m.id, m])); + for (const m of MAPS) { + for (const warp of m.warps ?? []) { + const dest = byId.get(warp.destMap); + expect(dest).toBeDefined(); + const destWarps = dest!.warps ?? []; + expect(destWarps.length).toBeGreaterThan(warp.destWarp); + } + } +}); + +test("every warp sits on a cell whose block actually has a door", () => { + // The bottom-left-tile rule means a door tile only counts if it is at the + // block-relative position the collision check reads. A warp on a cell with + // no door under it is a player standing on solid ground wondering why + // nothing happens. + const doorTiles = new Set( + Object.entries(TILE_BEHAVIOR) + .filter(([, behavior]) => behavior === 4 /* door */ || behavior === 5 /* warp */) + .map(([id]) => Number(id)), + ); + for (const m of MAPS) { + const { w, blocks } = blocksOf(m); + for (const warp of m.warps ?? []) { + const bx = Math.floor(warp.x / 2); + const by = Math.floor(warp.y / 2); + const block = blocks[by * w + bx]; + expect(block).toBeDefined(); + const tiles = BLOCK_TILES[block!]!; + // Cell (cx % 2, cy % 2) reads tile (2 * (cx % 2), 2 * (cy % 2) + 1). + const tx = 2 * (warp.x % 2); + const ty = 2 * (warp.y % 2) + 1; + const tile = tiles[ty * 4 + tx]!; + expect(doorTiles.has(tile)).toBe(true); + } + } +}); + +test("map connections are declared from both sides", () => { + const byId = new Map(MAPS.map((m) => [m.id, m])); + // north/south and west/east are indices 0/1 and 2/3. + const opposite = [1, 0, 3, 2]; + for (const m of MAPS) { + const conn = m.conn ?? [-1, -1, -1, -1]; + for (let side = 0; side < 4; side++) { + const id = conn[side]!; + if (id < 0) continue; + const other = byId.get(id); + expect(other).toBeDefined(); + const back = (other!.conn ?? [-1, -1, -1, -1])[opposite[side]!]; + expect(back).toBe(m.id); + } + } +}); + +test("connection offsets are mirrored, so a seam does not drift", () => { + const byId = new Map(MAPS.map((m) => [m.id, m])); + const opposite = [1, 0, 3, 2]; + for (const m of MAPS) { + const conn = m.conn ?? [-1, -1, -1, -1]; + const off = m.connOff ?? [0, 0, 0, 0]; + for (let side = 0; side < 4; side++) { + if (conn[side]! < 0) continue; + const other = byId.get(conn[side]!)!; + const back = (other.connOff ?? [0, 0, 0, 0])[opposite[side]!]!; + // Walking across and back must land on the cell you left. Summing + // rather than negating: JavaScript's -0 is not `toBe` 0. + expect(back + off[side]!).toBe(0); + } + } +}); + +test("every actor that names a script has one", () => { + const names = new Set(SCRIPTS.map((s) => s.name)); + for (const m of MAPS) { + for (const a of m.actors ?? []) { + if (a.script) expect(names.has(a.script)).toBe(true); + // An actor is either a talker, a script, or a trainer — never silent. + const speaks = Boolean(a.script || a.text || (a.trainer ?? -1) >= 0); + expect(speaks).toBe(true); + } + } +}); + +test("every actor sprite exists in the cast", () => { + for (const m of MAPS) { + for (const a of m.actors ?? []) { + expect(a.sprite).toBeLessThan(CAST.length); + } + } +}); + +test("every trainer an actor references exists", () => { + const ids = new Set(TRAINERS.map((t) => t.id)); + for (const m of MAPS) { + for (const a of m.actors ?? []) { + if ((a.trainer ?? -1) >= 0) expect(ids.has(a.trainer!)).toBe(true); + } + } +}); + +// --------------------------------------------------------------------------- +// Scripts +// --------------------------------------------------------------------------- + +test("every script assembles and ends", () => { + const text = new TextTable(); + for (const s of SCRIPTS) { + const bytes = assembleScript(s.rows, text); + expect(bytes.length).toBeGreaterThan(8); + // The last reachable instruction of every branch should be `end`; at + // minimum the script must contain one, or the VM runs off the end. + expect(s.rows.some((r) => r[0] === VERB.end)).toBe(true); + } +}); + +test("every jump names a label the script declares", () => { + for (const s of SCRIPTS) { + const labels = new Set( + s.rows.filter((r) => r[0] === VERB.label).map((r) => r[1] as string), + ); + for (const row of s.rows) { + for (const arg of row.slice(1)) { + if (typeof arg === "object" && arg !== null && "label" in arg) { + expect(labels.has((arg as { label: string }).label)).toBe(true); + } + } + } + } +}); + +test("assembling is deterministic", () => { + const a = assembleScript(SCRIPTS[0]!.rows, new TextTable()); + const b = assembleScript(SCRIPTS[0]!.rows, new TextTable()); + expect(Buffer.from(a).equals(Buffer.from(b))).toBe(true); +}); + +// --------------------------------------------------------------------------- +// Art +// --------------------------------------------------------------------------- + +test("every tile is eight by eight characters", () => { + for (const [id, rows] of Object.entries(TILE_ART)) { + expect(rows.length).toBe(8); + for (const row of rows) { + expect(row.length).toBe(8); + } + // Every drawn tile needs a behaviour, or it silently becomes a wall. + expect(TILE_BEHAVIOR[Number(id)]).toBeDefined(); + } +}); + +test("every block references drawn tiles", () => { + const drawn = new Set(Object.keys(TILE_ART).map(Number)); + for (const tiles of Object.values(BLOCK_TILES)) { + expect(tiles.length).toBe(16); + for (const t of tiles) expect(drawn.has(t)).toBe(true); + } +}); + +test("every font glyph is the declared size", () => { + for (const [ch, rows] of Object.entries(FONT)) { + expect(rows.length).toBe(GLYPH_H); + for (const row of rows) { + expect(row.length).toBe(GLYPH_W); + expect(/^[.#]*$/.test(row)).toBe(true); + } + expect(ch.length).toBeGreaterThan(0); + } +}); + +test("the font covers everything the content writes", () => { + // A missing glyph is invisible on screen and impossible to spot in a diff. + const covered = new Set(characters()); + const strings: string[] = [ + ...SPECIES.flatMap((s) => [s.name, s.dex]), + ...MOVES.flatMap((m) => [m.name, m.desc]), + ...ITEMS.flatMap((i) => [i.name, i.desc]), + ...TYPE_NAMES, + ...MAPS.map((m) => m.name), + ...MAPS.flatMap((m) => (m.signs ?? []).map((s) => s.text)), + ...MAPS.flatMap((m) => (m.actors ?? []).map((a) => a.text ?? "")), + ...TRAINERS.flatMap((t) => [t.name, t.intro, t.defeat]), + ...SCRIPTS.flatMap((s) => + s.rows.flatMap((r) => r.slice(1).filter((a): a is string => typeof a === "string")), + ), + ]; + const missing = new Set(); + for (const s of strings) { + for (const ch of s) { + // Control codes are handled by the text engine, not the font. + if (ch === "\n" || ch === " " || ch === " ") continue; + if (!covered.has(ch)) missing.add(ch); + } + } + expect([...missing].sort()).toEqual([]); +}); + +test("the text table interns and never hands out a duplicate key", () => { + const t = new TextTable(); + expect(t.key("")).toBe(0); + const a = t.key("HELLO"); + expect(t.key("HELLO")).toBe(a); + expect(t.key("WORLD")).not.toBe(a); + expect(t.all()[a]).toBe("HELLO"); +}); + +test("the built text table covers every id the records reference", () => { + const built = buildText(); + const size = built.text.size; + const keys = [ + ...built.speciesNameKeys, + ...built.speciesDexKeys, + ...built.moveNameKeys, + ...built.itemNameKeys, + ...built.mapNameKeys, + ...built.trainerNameKeys, + ...built.scriptKeys.values(), + ]; + for (const k of keys) { + expect(k).toBeGreaterThanOrEqual(0); + expect(k).toBeLessThan(size); + } + expect(new Set(built.scriptKeys.values()).size).toBe(SCRIPTS.length); +}); diff --git a/tests/mon-contract.test.ts b/tests/mon-contract.test.ts new file mode 100644 index 00000000..0d03e287 --- /dev/null +++ b/tests/mon-contract.test.ts @@ -0,0 +1,80 @@ +// Pocket Mon spec drift guard (docs/MON.md §3). +// +// Regenerates pocketmon-core/src/spec.rs IN-MEMORY from contracts/spec/mon-spec.ts +// and byte-compares against the committed file: TS and Rust constants can never +// drift. Fix = `bun contracts/spec/gen-mon-rust.ts` + commit. +// +// Also pins the surface's append-only invariants, which no regeneration can +// catch: op/event codes are unique and never reused. + +import { expect, test } from "bun:test"; +import { generateMonRust } from "../contracts/spec/gen-mon-rust.ts"; +import { + ENCOUNTER_BUCKETS, + MON_EVENT, + MON_OP, + MONPAK_TAG, + STAGE_MULT, + VERB, +} from "../contracts/spec/mon-spec.ts"; + +const specRsPath = new URL( + "../engine/pocketmon/crates/pocketmon-core/src/spec.rs", + import.meta.url, +).pathname; + +test("pocketmon-core/src/spec.rs matches mon-spec.ts", async () => { + const committed = await Bun.file(specRsPath).text(); + expect(committed).toBe(generateMonRust()); +}); + +test("op codes are unique and non-zero", () => { + const codes = Object.values(MON_OP); + expect(new Set(codes).size).toBe(codes.length); + expect(codes.every((c) => c > 0)).toBe(true); +}); + +test("event codes are unique and non-zero", () => { + const codes = Object.values(MON_EVENT); + expect(new Set(codes).size).toBe(codes.length); + expect(codes.every((c) => c > 0)).toBe(true); +}); + +test("script verbs are unique and dense from 0", () => { + const codes = Object.values(VERB); + expect(new Set(codes).size).toBe(codes.length); + expect(Math.min(...codes)).toBe(0); + expect(Math.max(...codes)).toBe(codes.length - 1); +}); + +test("MONPAK section tags are unique 4CCs", () => { + const tags = Object.values(MONPAK_TAG); + expect(new Set(tags).size).toBe(tags.length); + // every tag must be four printable ASCII bytes, so hexdumps stay readable + for (const tag of tags) { + for (let i = 0; i < 4; i++) { + const b = (tag >>> (i * 8)) & 0xff; + expect(b).toBeGreaterThanOrEqual(0x20); + expect(b).toBeLessThan(0x7f); + } + } +}); + +test("stat stage table is symmetric around stage 0", () => { + // 13 entries, stage -6..+6; index 6 is the identity. + expect(STAGE_MULT.length).toBe(13); + expect(STAGE_MULT[6]).toBe(100); + expect(STAGE_MULT[0]).toBe(25); + expect(STAGE_MULT[12]).toBe(400); + // monotonic increasing + for (let i = 1; i < STAGE_MULT.length; i++) { + expect(STAGE_MULT[i]).toBeGreaterThan(STAGE_MULT[i - 1]); + } +}); + +test("encounter buckets are cumulative and end at 256", () => { + expect(ENCOUNTER_BUCKETS[ENCOUNTER_BUCKETS.length - 1]).toBe(256); + for (let i = 1; i < ENCOUNTER_BUCKETS.length; i++) { + expect(ENCOUNTER_BUCKETS[i]).toBeGreaterThan(ENCOUNTER_BUCKETS[i - 1]); + } +}); diff --git a/tools/mon.ts b/tools/mon.ts new file mode 100644 index 00000000..276eca40 --- /dev/null +++ b/tools/mon.ts @@ -0,0 +1,189 @@ +// tools/mon.ts — the Pocket Mon command (docs/MON.md). +// +// bun tools/mon.ts cook cook apps/mon into dist/sparkwood.monpak +// bun tools/mon.ts sim [args…] cook, then run the headless simulator +// bun tools/mon.ts check cook + run the story tape against goldens +// bun tools/mon.ts record cook + re-record the golden hashes +// bun tools/mon.ts shots [dir] cook + write a PNG per checkpoint +// bun tools/mon.ts psp [cargo args] cook, then build the PSP EBOOT +// bun tools/mon.ts run …and launch it in PPSSPP +// +// The pak is cooked first by every subcommand on purpose: content lives in +// TypeScript, and a stale pak is the one failure that looks like an engine bug +// (see the repo-wide "stale dist is target-flavored" lesson). + +import { $ } from "bun"; +import { existsSync, mkdirSync } from "node:fs"; +import { resolvePspBuildToolchain } from "./psp-toolchain.ts"; + +const root = new URL("..", import.meta.url).pathname; +const monDir = `${root}engine/pocketmon/crates`; +const pakPath = `${root}dist/sparkwood.monpak`; +const simDir = `${monDir}/pocketmon-sim`; +const eboot = `${monDir}/pocketmon-psp`; +const tape = `${root}apps/mon/tapes/story.tape`; +const goldens = `${root}tests/goldens/mon/story.hashes`; + +const argv = Bun.argv.slice(2); +const cmd = argv[0] ?? "check"; +const rest = argv.slice(1); + +function usage(): never { + console.error( + "usage: bun tools/mon.ts [args…]", + ); + process.exit(2); +} + +/** Cook the content pak. Always runs: content is source. */ +async function cook(): Promise { + mkdirSync(`${root}dist`, { recursive: true }); + await $`bun ${root}apps/mon/cook.ts ${pakPath}`.cwd(root); +} + +/** Build the headless simulator and return its binary path. */ +async function buildSim(): Promise { + await $`cargo build --release`.cwd(simDir).quiet(); + return `${simDir}/target/release/pocketmon-sim`; +} + +async function main() { + switch (cmd) { + case "cook": + await cook(); + return; + + case "sim": { + await cook(); + const bin = await buildSim(); + await $`${bin} ${pakPath} ${rest}`; + return; + } + + case "check": { + await cook(); + const bin = await buildSim(); + await $`${bin} ${pakPath} --tape ${tape} --hashes ${goldens} --assert`; + return; + } + + case "record": { + await cook(); + const bin = await buildSim(); + mkdirSync(`${root}tests/goldens/mon`, { recursive: true }); + await $`${bin} ${pakPath} --tape ${tape} --hashes ${goldens}`; + console.log( + "\nRecorded. Read the diff before committing: a changed hash is either a" + + "\nfix or a regression, and the file cannot tell you which.", + ); + return; + } + + case "shots": { + await cook(); + const bin = await buildSim(); + const dir = rest[0] ?? `${root}dist/mon-shots`; + mkdirSync(dir, { recursive: true }); + await $`${bin} ${pakPath} --tape ${tape} --shots ${dir} --atlas ${dir}`; + console.log(`\nwrote PNGs to ${dir}`); + return; + } + + case "psp": + case "run": { + await cook(); + await buildEboot(rest); + if (cmd === "run") await launchPpsspp(); + return; + } + + default: + usage(); + } +} + +async function buildEboot(cargoArgs: string[]): Promise { + let toolchain: ReturnType; + try { + toolchain = resolvePspBuildToolchain(); + } catch (error) { + console.error(String((error as Error).message ?? error)); + process.exit(1); + } + const sdk = toolchain.sdk.path; + + // cargo-psp reads Psp.toml from its working directory for the PBP metadata. + const pspToml = `${eboot}/Psp.toml`; + if (!existsSync(pspToml)) { + await Bun.write( + pspToml, + [ + "# Generated by tools/mon.ts — cargo-psp reads this for the PBP header.", + '[psp]', + 'title = "SPARKWOOD"', + "", + ].join("\n"), + ); + } + + const rustflags = [ + process.env.RUSTFLAGS ?? "", + // rust-psp's linker script emits messages cargo would treat as warnings. + "-A linker-messages", + ] + .filter(Boolean) + .join(" "); + + const env = { + ...toolchain.environment, + RUSTFLAGS: rustflags, + CRATE_CC_NO_DEFAULTS: "1", + TARGET_CC: "clang", + TARGET_AR: `${toolchain.llvmBin}/llvm-ar`, + TARGET_CFLAGS: + `-target mipsel-sony-psp -mcpu=mips2 -msingle-float -mlittle-endian -mno-abicalls ` + + `-fno-pic -G0 -mno-check-zero-division -fno-stack-protector ` + + `-I${sdk}/psp/include -I${sdk}/psp/sdk/include`, + AR_mipsel_sony_psp: `${toolchain.llvmBin}/llvm-ar`, + RANLIB_mipsel_sony_psp: `${toolchain.llvmBin}/llvm-ranlib`, + RUST_PSP_TARGET: `${root}hosts/psp/targets/mipsel-sony-psp.json`, + RUST_PSP_ABORT_ONLY: "1", + // opt-level 0 is unusably slow on a 333 MHz console, even in a dev build. + CARGO_PROFILE_DEV_OPT_LEVEL: process.env.CARGO_PROFILE_DEV_OPT_LEVEL ?? "3", + // The cooked game, baked in by `include_bytes!`. + SPARKWOOD_PAK: pakPath, + // Capture-build inputs (only read under --features capture; set + // unconditionally so a stale value cannot linger in cargo's fingerprint). + MON_CAPTURE_INPUT: process.env.MON_CAPTURE_INPUT ?? "", + MON_CAP_START: process.env.MON_CAP_START ?? "0", + MON_CAP_N: process.env.MON_CAP_N ?? "64", + }; + + console.log("pocket mon: cargo psp"); + await $`${toolchain.rustup} run ${toolchain.manifest.rust.toolchain} cargo psp ${cargoArgs}` + .cwd(eboot) + .env(env); + + const out = `${eboot}/target/mipsel-sony-psp/debug/EBOOT.PBP`; + if (existsSync(out)) console.log(`\nEBOOT: ${out}`); +} + +async function launchPpsspp(): Promise { + const candidates = [ + "/Applications/PPSSPPSDL.app/Contents/MacOS/PPSSPPSDL", + "/Applications/PPSSPP.app/Contents/MacOS/PPSSPP", + ]; + const ppsspp = candidates.find((p) => existsSync(p)); + if (!ppsspp) { + console.error("PPSSPP not found in /Applications — build only."); + return; + } + const pbp = `${eboot}/target/mipsel-sony-psp/debug/EBOOT.PBP`; + if (!existsSync(pbp)) { + console.error(`no EBOOT at ${pbp}`); + process.exit(1); + } + await $`${ppsspp} ${pbp}`; +} + +await main(); From aff93eb279a83a326a322fb4372fe3990d63d143 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:28:08 +0800 Subject: [PATCH 2/5] test(mon): drive the console e2e from the same tape as the simulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PSP e2e had its own hand-written, frame-counted input script, which described a shorter journey than the sim tape and would have drifted from it the first time a walk cadence changed. Two descriptions of one journey is one too many. `pocketmon-sim --emit-psp` now records the per-frame input its intent tape produced and writes it out with the frame each checkpoint landed on; the capture EBOOT replays that verbatim. This is only sound because the core is identical and deterministic on both hosts — the property the runtime is built around — so the console now walks the whole story: out of the house, across the village, the professor's script, the seam into Route One, and two won wild battles. 14/14 checkpoints, 2877 frames. Also asserts backend agreement: every checkpoint is compared pixel for pixel against the software rasterizer's own PNG. The GE path and the reference rasterizer are separate implementations of one draw list, and without this the sim goldens could describe a picture the console never shows. All 14 currently agree exactly. Two fixes fell out of building it: - a `settle` call in the `clear` command was not recording its frames, so the replay log and the frame counter disagreed by four and the last checkpoint fell past the end of the run. The emit path now takes the frame number FROM the log, and the sim hard-fails if any tick escapes it. - the sim can render before it has ticked at all and a console cannot, so the tape now spends one frame before its first checkpoint. That was the only pixel difference between the two backends. --- apps/mon/tapes/story.tape | 22 ++-- docs/MON.md | 18 ++- .../crates/pocketmon-psp/src/capture.rs | 106 ++++++++++-------- .../crates/pocketmon-sim/src/main.rs | 99 +++++++++++++++- tests/e2e/mon-ppsspp.ts | 105 ++++++++++------- .../goldens/mon/psp/{boot.png => 00-wake.png} | Bin .../mon/psp/{village.png => 01-village.png} | Bin tests/goldens/mon/psp/02-inside-lab.png | Bin 0 -> 2570 bytes tests/goldens/mon/psp/03-facing-professor.png | Bin 0 -> 2964 bytes tests/goldens/mon/psp/04-partner-in-hand.png | Bin 0 -> 2964 bytes tests/goldens/mon/psp/05-back-outside.png | Bin 0 -> 3318 bytes tests/goldens/mon/psp/06-route-one.png | Bin 0 -> 3018 bytes tests/goldens/mon/psp/07-in-the-grass.png | Bin 0 -> 3226 bytes tests/goldens/mon/psp/08-wild-appears.png | Bin 0 -> 2868 bytes tests/goldens/mon/psp/09-action-menu.png | Bin 0 -> 3139 bytes tests/goldens/mon/psp/10-move-menu.png | Bin 0 -> 2986 bytes tests/goldens/mon/psp/11-battle-over.png | Bin 0 -> 3379 bytes tests/goldens/mon/psp/12-second-encounter.png | Bin 0 -> 2933 bytes tests/goldens/mon/psp/13-still-standing.png | Bin 0 -> 3374 bytes tests/goldens/mon/psp/doormat.png | Bin 3531 -> 0 bytes tests/goldens/mon/psp/walking.png | Bin 2835 -> 0 bytes tests/goldens/mon/story.hashes | 18 +-- tools/mon.ts | 4 +- 23 files changed, 252 insertions(+), 120 deletions(-) rename tests/goldens/mon/psp/{boot.png => 00-wake.png} (100%) rename tests/goldens/mon/psp/{village.png => 01-village.png} (100%) create mode 100644 tests/goldens/mon/psp/02-inside-lab.png create mode 100644 tests/goldens/mon/psp/03-facing-professor.png create mode 100644 tests/goldens/mon/psp/04-partner-in-hand.png create mode 100644 tests/goldens/mon/psp/05-back-outside.png create mode 100644 tests/goldens/mon/psp/06-route-one.png create mode 100644 tests/goldens/mon/psp/07-in-the-grass.png create mode 100644 tests/goldens/mon/psp/08-wild-appears.png create mode 100644 tests/goldens/mon/psp/09-action-menu.png create mode 100644 tests/goldens/mon/psp/10-move-menu.png create mode 100644 tests/goldens/mon/psp/11-battle-over.png create mode 100644 tests/goldens/mon/psp/12-second-encounter.png create mode 100644 tests/goldens/mon/psp/13-still-standing.png delete mode 100644 tests/goldens/mon/psp/doormat.png delete mode 100644 tests/goldens/mon/psp/walking.png diff --git a/apps/mon/tapes/story.tape b/apps/mon/tapes/story.tape index 65873d9d..d5c7c082 100644 --- a/apps/mon/tapes/story.tape +++ b/apps/mon/tapes/story.tape @@ -11,6 +11,10 @@ # wait idle (fades, message holds) # mark capture + hash the frame +# One frame before the first checkpoint: the sim can render before it has +# ticked at all, and a console cannot — its first presented frame is already +# post-tick. Starting here gives every checkpoint an exact console counterpart. +wait 1 mark 00-wake # --- out of the house ---------------------------------------------------- @@ -43,31 +47,31 @@ mark 04-partner-in-hand walk d 2 walk r 2 wait 40 -mark 06-back-outside +mark 05-back-outside walk d 4 walk l 3 walk u 9 walk u 1 wait 40 -mark 07-route-one +mark 06-route-one # --- the first wild battle ------------------------------------------------ walk u 3 -mark 08-in-the-grass +mark 07-in-the-grass grind ud 900 wait 20 -mark 09-wild-appears +mark 08-wild-appears clear -mark 10-action-menu +mark 09-action-menu press a wait 20 -mark 11-move-menu +mark 10-move-menu fight -mark 12-battle-over +mark 11-battle-over # --- and again, to prove the loop repeats --------------------------------- grind ud 900 wait 20 -mark 13-second-encounter +mark 12-second-encounter fight -mark 14-still-standing +mark 13-still-standing diff --git a/docs/MON.md b/docs/MON.md index 051550d3..04652670 100644 --- a/docs/MON.md +++ b/docs/MON.md @@ -212,10 +212,20 @@ A runtime without a headless story is not done (discipline #4): house, across the village, take a starter from the professor, north to Route One, and win two wild battles, asserted against recorded frame hashes. `bun tools/mon.ts shots` writes a PNG per checkpoint to look at. -- **`bun tests/e2e/mon-ppsspp.ts`** — the same journey on the console: a - capture EBOOT with a baked input tape under PPSSPPHeadless's software - renderer, checked for liveness (every frame of the window presented) and - byte-compared against committed PNG goldens. +- **`bun tests/e2e/mon-ppsspp.ts`** — the same journey **on the console**. + There is no second tape: `pocketmon-sim --emit-psp` replays the intent tape + above and writes out the per-frame input it produced, which the capture + EBOOT then replays verbatim under PPSSPPHeadless's software renderer. That + works because the core is identical and deterministic on both hosts, which + is the runtime's whole premise being cashed in rather than a trick. + + Three things are asserted: **liveness** (all 14 checkpoints reached — 2877 + frames, boot through two won battles, so a boot hang, a content-parse halt + or a wedged loop all fail loudly), **byte-exact PNG goldens**, and + **backend agreement** — every checkpoint is compared pixel for pixel against + the software rasterizer's own output. The GE path and the reference + rasterizer are separate implementations of one draw list; if they disagree, + the sim goldens are describing a picture the console never shows. - Real hardware, over PSPLINK — **not yet run**. ## 7. Out of scope for v1 diff --git a/engine/pocketmon/crates/pocketmon-psp/src/capture.rs b/engine/pocketmon/crates/pocketmon-psp/src/capture.rs index cea5d5cd..58dc82e8 100644 --- a/engine/pocketmon/crates/pocketmon-psp/src/capture.rs +++ b/engine/pocketmon/crates/pocketmon-psp/src/capture.rs @@ -18,8 +18,12 @@ use psp::sys::{self, DisplayPixelFormat, DisplaySetBufSync, IoOpenFlags}; /// `"frame:mask,frame:mask,…"` — the active mask is the last threshold at or /// before the current frame. Baked by tools/mon.ts from the e2e spec. const INPUT: &str = env!("MON_CAPTURE_INPUT"); -const CAP_START: &str = env!("MON_CAP_START"); -const CAP_N: &str = env!("MON_CAP_N"); +/// Comma-separated frame indices to dump. Only these frames are written: a +/// full playthrough is ~2900 frames, and dumping all of them would be 1.6 GB +/// of raw framebuffers to compare four pictures. +const FRAMES: &str = env!("MON_CAP_FRAMES"); +/// The frame to exit on, so the headless run terminates by itself. +const EXIT_AT: &str = env!("MON_CAP_EXIT"); fn env_u32(s: &str, default: u32) -> u32 { let mut v: u32 = 0; @@ -38,12 +42,20 @@ fn env_u32(s: &str, default: u32) -> u32 { } } -pub fn cap_start() -> u32 { - env_u32(CAP_START, 0) -} - -pub fn cap_n() -> u32 { - env_u32(CAP_N, 64) +/// Is this frame one of the ones being captured, and at what index? +fn shot_index(frame: u32) -> Option { + let mut i = 0; + for entry in FRAMES.split(',') { + let entry = entry.trim(); + if entry.is_empty() { + continue; + } + if env_u32(entry, u32::MAX) == frame { + return Some(i); + } + i += 1; + } + None } /// Parse one `frame:mask` pair. Masks may be decimal or `0x`-prefixed. @@ -92,54 +104,50 @@ pub fn scripted_buttons(frame: u32) -> u32 { best } -/// Dump the presented framebuffer for frames inside the window, then exit the -/// game on the last one so the headless run terminates on its own. +/// Dump the presented framebuffer if this frame is a checkpoint, and exit the +/// game once the run is over. pub unsafe fn dump_frame(frame: u32) { - let start = cap_start(); - let n = cap_n(); - if frame < start || frame >= start + n { - return; - } - let idx = frame - start; - if idx == 0 { + if frame == 0 { sys::sceIoMkdir(b"ms0:/mon_cap\0".as_ptr(), 0o777); } - // "ms0:/mon_cap/fNNNN.raw\0", digits at offsets 14..=17. - let mut name: [u8; 23] = *b"ms0:/mon_cap/f0000.raw\0"; - let mut v = idx; - let mut i = 17usize; - loop { - name[i] = b'0' + (v % 10) as u8; - v /= 10; - if i == 14 { - break; + if let Some(idx) = shot_index(frame) { + // "ms0:/mon_cap/fNNNN.raw\0", digits at offsets 14..=17. + let mut name: [u8; 23] = *b"ms0:/mon_cap/f0000.raw\0"; + let mut v = idx; + let mut i = 17usize; + loop { + name[i] = b'0' + (v % 10) as u8; + v /= 10; + if i == 14 { + break; + } + i -= 1; } - i -= 1; - } - // Read straight from VRAM through the uncached mirror: the GE's output is - // not in the CPU's cache, and reading the cached view yields whatever was - // there before. - let mut top: *mut c_void = core::ptr::null_mut(); - let mut bw: usize = 0; - let mut fmt = DisplayPixelFormat::Psm8888; - sys::sceDisplayGetFrameBuf(&mut top, &mut bw, &mut fmt, DisplaySetBufSync::Immediate); - let mut addr = top as u32; - if addr < 0x0400_0000 { - addr += 0x0400_0000; - } - addr |= 0x4000_0000; + // Read straight from VRAM through the uncached mirror: the GE's output + // is not in the CPU's cache, and the cached view holds whatever was + // there before. + let mut top: *mut c_void = core::ptr::null_mut(); + let mut bw: usize = 0; + let mut fmt = DisplayPixelFormat::Psm8888; + sys::sceDisplayGetFrameBuf(&mut top, &mut bw, &mut fmt, DisplaySetBufSync::Immediate); + let mut addr = top as u32; + if addr < 0x0400_0000 { + addr += 0x0400_0000; + } + addr |= 0x4000_0000; - let fd = sys::sceIoOpen( - name.as_ptr(), - IoOpenFlags::CREAT | IoOpenFlags::WR_ONLY | IoOpenFlags::TRUNC, - 0o777, - ); - if fd.0 >= 0 { - sys::sceIoWrite(fd, addr as *const c_void, 512 * 272 * 4); - sys::sceIoClose(fd); + let fd = sys::sceIoOpen( + name.as_ptr(), + IoOpenFlags::CREAT | IoOpenFlags::WR_ONLY | IoOpenFlags::TRUNC, + 0o777, + ); + if fd.0 >= 0 { + sys::sceIoWrite(fd, addr as *const c_void, 512 * 272 * 4); + sys::sceIoClose(fd); + } } - if idx + 1 == n { + if frame >= env_u32(EXIT_AT, 600) { sys::sceKernelExitGame(); } } diff --git a/engine/pocketmon/crates/pocketmon-sim/src/main.rs b/engine/pocketmon/crates/pocketmon-sim/src/main.rs index de286cda..a511f209 100644 --- a/engine/pocketmon/crates/pocketmon-sim/src/main.rs +++ b/engine/pocketmon/crates/pocketmon-sim/src/main.rs @@ -162,6 +162,7 @@ fn main() { let mut scale = 2u32; let mut assert_mode = false; let mut atlas_dir: Option = None; + let mut emit_psp: Option = None; let mut i = 0; while i < args.len() { @@ -194,6 +195,10 @@ fn main() { i += 1; atlas_dir = args.get(i).map(PathBuf::from); } + "--emit-psp" => { + i += 1; + emit_psp = args.get(i).map(PathBuf::from); + } "--assert" => assert_mode = true, other => { if pak_path.as_os_str().is_empty() { @@ -278,12 +283,22 @@ fn main() { let mut captured: BTreeMap = BTreeMap::new(); let mut total = 0u32; let mut stalled = 0u32; + // The button mask of every frame, and the frame each checkpoint landed on. + // + // This is what lets ONE tape drive both hosts: the core is deterministic + // and identical on both, so replaying this exact per-frame input on a PSP + // reproduces this exact run. The alternative — hand-writing a second, + // frame-counted tape for the console — is two descriptions of one journey, + // and they drift the first time the walk cadence changes. + let mut input_log: Vec = Vec::new(); + let mut mark_frames: Vec<(String, u32)> = Vec::new(); for cmd in &cmds { match cmd { Cmd::Wait { frames } => { for _ in 0..*frames { game.tick(0); + input_log.push(0); total += 1; } } @@ -292,10 +307,12 @@ fn main() { // so this is exactly one press no matter the hold length. for _ in 0..4 { game.tick(*buttons); + input_log.push(*buttons); total += 1; } for _ in 0..8 { game.tick(0); + input_log.push(0); total += 1; } } @@ -319,13 +336,14 @@ fn main() { break; } game.tick(*dir); + input_log.push(*dir); total += 1; spent += 1; if game.world.map_id != start_map { break; } } - total += settle(&mut game); + total += settle_logged(&mut game, &mut input_log); let walked = game.world.steps.wrapping_sub(before); if walked < *cells && game.world.map_id == start_map { println!( @@ -342,6 +360,7 @@ fn main() { while pressed < *cap && waiting_on_a(&game) { for _ in 0..4 { game.tick(spec::btn::A); + input_log.push(spec::btn::A); total += 1; } // Bail the FRAME the conversation ends, not at the end of a @@ -353,6 +372,7 @@ fn main() { break; } game.tick(0); + input_log.push(0); total += 1; } pressed += 1; @@ -369,7 +389,7 @@ fn main() { println!(" !! clear gave up after {pressed} presses"); stalled += 1; } - total += settle(&mut game); + total += settle_logged(&mut game, &mut input_log); } Cmd::Fight { cap } => { let mut acted = 0; @@ -386,10 +406,12 @@ fn main() { }; for _ in 0..4 { game.tick(button); + input_log.push(button); total += 1; } for _ in 0..12 { game.tick(0); + input_log.push(0); total += 1; } acted += 1; @@ -398,7 +420,7 @@ fn main() { println!(" !! fight did not finish in {acted} actions"); stalled += 1; } - total += settle(&mut game); + total += settle_logged(&mut game, &mut input_log); } Cmd::Grind { a, b, cap } => { let mut spent = 0; @@ -416,11 +438,12 @@ fn main() { break; } game.tick(dir); + input_log.push(dir); total += 1; spent += 1; stuck += 1; } - let settled = settle(&mut game); + let settled = settle_logged(&mut game, &mut input_log); total += settled; spent += settled; dir = if dir == *a { *b } else { *a }; @@ -439,6 +462,7 @@ fn main() { game.draw = list; let hash = format!("{:016x}", frame.hash()); captured.insert(name.clone(), hash.clone()); + mark_frames.push((name.clone(), input_log.len() as u32)); if let Some(dir) = &shots { let path = dir.join(format!("{name}.png")); let bytes = png::encode_rgba(frame.w, frame.h, &frame.px); @@ -469,9 +493,24 @@ fn main() { if stalled > 0 { println!("\n{stalled} walk(s) stalled — the tape and the map disagree"); } + // The console replays `input_log` frame for frame. If a tick ever escapes + // the log, the two hosts silently run different journeys and the PSP + // goldens become fiction — so this is an assertion, not a warning. + if input_log.len() as u32 != total { + eprintln!( + "internal error: {} frames ticked but {} logged — a tick site is not recording input", + total, + input_log.len(), + ); + std::process::exit(1); + } println!("ran {total} frames, {} checkpoints", captured.len()); report_state(&game); + if let Some(path) = &emit_psp { + write_psp_plan(path, &input_log, &mark_frames); + } + if let Some(path) = &hashes_path { if assert_mode { compare_hashes(path, &captured); @@ -502,6 +541,15 @@ fn waiting_on_a(game: &Game) -> bool { } } +/// [`settle`], recording each frame's (empty) input into the replay log. +fn settle_logged(game: &mut Game, log: &mut Vec) -> u32 { + let spent = settle(game); + for _ in 0..spent { + log.push(0); + } + spent +} + /// Tick with nothing held until the player is idle. Returns frames spent. fn settle(game: &mut Game) -> u32 { let mut spent = 0; @@ -558,6 +606,49 @@ fn report_state(game: &Game) { } } +/// Emit the console replay plan: the per-frame input compressed to threshold +/// pairs, plus the frame each checkpoint landed on. +/// +/// The PSP capture build replays this verbatim, which is how the two harnesses +/// stay one journey rather than two. Format is a tiny JSON object so the e2e +/// driver can read it without a parser. +fn write_psp_plan(path: &Path, input: &[u32], marks: &[(String, u32)]) { + // Compress: only frames where the mask CHANGES need an entry, because the + // console side resolves "the last threshold at or before this frame". + let mut pairs: Vec = Vec::new(); + let mut last: Option = None; + for (frame, &mask) in input.iter().enumerate() { + if last != Some(mask) { + pairs.push(format!("{frame}:{mask}")); + last = Some(mask); + } + } + let shots = marks + .iter() + // A mark records the frame count AFTER its command; the frame actually + // rendered is the last one ticked. + .map(|(name, frame)| format!("{{\"name\":\"{name}\",\"frame\":{}}}", frame.saturating_sub(1))) + .collect::>() + .join(","); + let json = format!( + "{{\n \"frames\": {},\n \"input\": \"{}\",\n \"shots\": [{}]\n}}\n", + input.len(), + pairs.join(","), + shots, + ); + if let Err(e) = std::fs::write(path, json) { + eprintln!("cannot write {}: {e}", path.display()); + std::process::exit(1); + } + println!( + "wrote {} ({} frames, {} input transitions, {} shots)", + path.display(), + input.len(), + pairs.len(), + marks.len(), + ); +} + fn write_hashes(path: &Path, captured: &BTreeMap) { let mut out = String::from("# pocketmon-sim frame hashes\n"); for (name, hash) in captured { diff --git a/tests/e2e/mon-ppsspp.ts b/tests/e2e/mon-ppsspp.ts index 408239f4..067e0ffc 100644 --- a/tests/e2e/mon-ppsspp.ts +++ b/tests/e2e/mon-ppsspp.ts @@ -33,35 +33,23 @@ const update = process.env.UPDATE === "1"; // --------------------------------------------------------------------------- // The run. // -// Button masks are `spec::btn` values from contracts/spec/mon-spec.ts: -// up 1 down 2 left 4 right 8 a 16 b 32 start 64 select 128 +// There is no second, hand-written tape here. `pocketmon-sim --emit-psp` +// replays the SAME intent tape the sim goldens use and writes out the +// per-frame input it produced, plus the frame each checkpoint landed on. The +// console build replays that verbatim. // -// The tape below walks the player out of the house the same way the sim tape -// does — down four, right four, onto the doormat — so the two harnesses are -// describing the same journey through the same content. +// It works because the core is identical and deterministic on both hosts — +// which is precisely the property the whole runtime is built around, so using +// it here is not a trick, it is the thesis being cashed in. The alternative, +// two descriptions of one journey, drifts the first time a walk cadence +// changes. // --------------------------------------------------------------------------- -const INPUT = [ - "0:0", // a beat on the bedroom floor - "20:2", // hold DOWN: four steps south - "110:0", - "130:8", // hold RIGHT: four steps east, onto the mat - "220:0", // the warp fade runs on its own -].join(","); - -const CAP_START = 0; -const CAP_N = 300; - -interface Shot { - name: string; - frame: number; -} -const SHOTS: Shot[] = [ - { name: "boot", frame: 8 }, // the bedroom, content parsed and drawn - { name: "walking", frame: 60 }, // mid-stride, camera following - { name: "doormat", frame: 215 }, // standing on the mat, warp starting - { name: "village", frame: 290 }, // outside, on the other side of the fade -]; +interface Plan { + frames: number; + input: string; + shots: Array<{ name: string; frame: number }>; +} // --------------------------------------------------------------------------- @@ -78,47 +66,62 @@ rmSync(outDir, { recursive: true, force: true }); mkdirSync(outDir, { recursive: true }); mkdirSync(goldensDir, { recursive: true }); +console.log("# derive the console run from the sim tape ..."); +await $`cargo build --release`.cwd(`${root}engine/pocketmon/crates/pocketmon-sim`).quiet(); +await $`bun tools/mon.ts cook`.cwd(root).quiet(); +const planPath = `${outDir}/plan.json`; +await $`${root}engine/pocketmon/crates/pocketmon-sim/target/release/pocketmon-sim ${root}dist/sparkwood.monpak --tape ${root}apps/mon/tapes/story.tape --emit-psp ${planPath}`.quiet(); +const plan = JSON.parse(readFileSync(planPath, "utf8")) as Plan; +const shots = plan.shots; +console.log( + ` ${plan.frames} frames, ${plan.input.split(",").length} input transitions, ${shots.length} checkpoints`, +); + +// The sim's own PNGs, for the backend cross-check below. +const simShots = `${outDir}/sim`; +mkdirSync(simShots, { recursive: true }); +await $`${root}engine/pocketmon/crates/pocketmon-sim/target/release/pocketmon-sim ${root}dist/sparkwood.monpak --tape ${root}apps/mon/tapes/story.tape --shots ${simShots}`.quiet(); + console.log("# build the capture EBOOT ..."); await $`bun tools/mon.ts psp --features capture` .cwd(root) .env({ ...process.env, - MON_CAPTURE_INPUT: INPUT, - MON_CAP_START: String(CAP_START), - MON_CAP_N: String(CAP_N), + MON_CAPTURE_INPUT: plan.input, + MON_CAP_FRAMES: shots.map((s) => s.frame).join(","), + // A couple of frames past the last checkpoint, so the run ends on its own. + MON_CAP_EXIT: String(plan.frames + 2), }) .quiet(); console.log("# PPSSPPHeadless (software renderer) ..."); rmSync(capDir, { recursive: true, force: true }); -const timeout = Number(process.env.E2E_TIMEOUT || 90); +const timeout = Number(process.env.E2E_TIMEOUT || 240); const run = await $`${headless} --graphics=software --timeout=${timeout} ${eboot}` .cwd("/tmp") .nothrow() .quiet(); -// Liveness: did the loop present every frame of the window? This alone catches -// a boot hang, a content-parse failure (the EBOOT halts) and a wedged frame -// loop — the three ways "runs on hardware" usually fails. +// Liveness: every checkpoint dumped means the console got all the way through +// the run — boot, content parse, the professor's script, the seam into Route +// One, and two wild battles. This alone catches the three ways "runs on +// hardware" usually fails: a boot hang, a content-parse halt, and a wedged +// frame loop. const produced = existsSync(capDir) ? readdirSync(capDir).filter((f) => /^f\d{4}\.raw$/.test(f)).length : 0; -if (produced !== CAP_N) { +if (produced !== shots.length) { console.error( - `FAIL: produced ${produced}/${CAP_N} capture frames within ${timeout}s.\n` + + `FAIL: dumped ${produced}/${shots.length} checkpoints within ${timeout}s.\n` + `PPSSPP output:\n${run.stdout}${run.stderr}`, ); process.exit(1); } -console.log(`liveness: ${produced}/${CAP_N} frames presented`); +console.log(`liveness: ${produced}/${shots.length} checkpoints reached on the console`); let failed = false; -for (const shot of SHOTS) { - if (shot.frame < CAP_START || shot.frame >= CAP_START + CAP_N) { - throw new Error(`${shot.name}: frame ${shot.frame} is outside the capture window`); - } - const idx = String(shot.frame - CAP_START).padStart(4, "0"); - const raw = `${capDir}/f${idx}.raw`; +for (const [i, shot] of shots.entries()) { + const raw = `${capDir}/f${String(i).padStart(4, "0")}.raw`; // Refuse a flat frame even when regenerating: a golden that is all one // colour records nothing, and would happily "pass" forever. @@ -141,6 +144,22 @@ for (const shot of SHOTS) { const png = `${outDir}/${shot.name}.png`; await $`magick -size 512x272 -depth 8 RGBA:${raw} -alpha off -crop 480x272+0+0 +repage -depth 8 -define png:exclude-chunks=date,time PNG24:${png}`.quiet(); + // Cross-check the two backends. The software rasterizer in pocketmon-sim + // and the GE path in pocketmon-gu are separate implementations of the same + // draw list; if they ever disagree, one of them is wrong, and the sim + // goldens would be describing a picture the console never shows. + const simShot = `${simShots}/${shot.name}.png`; + if (existsSync(simShot)) { + const diff = await $`magick compare -metric AE ${png} ${simShot} null:`.nothrow().quiet(); + const differing = Number((diff.stderr.toString() || diff.stdout.toString()).split(" ")[0]) || 0; + if (differing !== 0) { + console.error( + ` FAIL ${shot.name}: the GE and the software rasterizer disagree on ${differing} pixels`, + ); + failed = true; + } + } + const golden = `${goldensDir}/${shot.name}.png`; if (update || !existsSync(golden)) { await $`cp ${png} ${golden}`.quiet(); @@ -160,4 +179,4 @@ if (failed) { console.error("\nmon e2e: FAILED"); process.exit(1); } -console.log(`\nmon e2e: all ${SHOTS.length} shots ${update ? "recorded" : "match"}`); +console.log(`\nmon e2e: all ${shots.length} shots ${update ? "recorded" : "match"}`); diff --git a/tests/goldens/mon/psp/boot.png b/tests/goldens/mon/psp/00-wake.png similarity index 100% rename from tests/goldens/mon/psp/boot.png rename to tests/goldens/mon/psp/00-wake.png diff --git a/tests/goldens/mon/psp/village.png b/tests/goldens/mon/psp/01-village.png similarity index 100% rename from tests/goldens/mon/psp/village.png rename to tests/goldens/mon/psp/01-village.png diff --git a/tests/goldens/mon/psp/02-inside-lab.png b/tests/goldens/mon/psp/02-inside-lab.png new file mode 100644 index 0000000000000000000000000000000000000000..fc977deff0613660da21f18529e5eca38164b22e GIT binary patch literal 2570 zcmeH}dr(t%7RRrZ3i#N##g>%;f@_6l#bS9#c^Ec`MP7mkzDcYI1Q|pyBm|N`p)R{i zS;j&IgqW~QM6rSkQECEYC{+qb2rnbVBrOoaBP5U~d6B(0T5D%#|KC4$?%cWa`~B|u z{m%KE?>YDWE$(Rew#`nP0RY$*aU?7r05<#t-rMco0cWHi?iYX$x8x)7F#wS74gf`8 z0)PRWDw+p?-2DKscpd-(YXHFUN!mK6R``2LHo;s%SKJ~uZTn}zQF6TITpIGqu9Qo1hMD1JM8cw`<|Mk zjH*9=RT}pdg6dl}-LT_WFm~n6t{oMwzIpR2dqx91mgFkJb6uxGXN;LsI_q(dbw4Y{ z2$pjObuXE9iox41f2-4xa&?KHmUzC6;yPsV)j zrWi`yvk&R+(36f^-~cMqx7Khhq_eIg+#MWQjn3}Hzcr)@dnL5GSVAIBGVKc_C!6^jzyqWe}`D!ZCd-*G1f0Uu|XS5CU5=(t5lW)y}$%@74$~8q#xG zSwLXouN&MY^UewcjH;&6dz5CsAriujB%_n&hJ&bJSWH;HHGtdr>~E17KN66Jk)SA= zrq5#X(i$Y`bs(22&|?M|j~^P^M=WBwSt8r1!bz@NUvUx53C_&uq;FW9SVPV;#qA!m z?5vG=@m3R7fk#NrE3^b}RmDrQd4XW((1KY{9OX6|sii)sjbAoX>Vz=S<8zUdqN!ky zt841{4}C0Bgp^(GY(eIAs^4ElgMZwanFQ~&*SuV(r7@aS=AFEF%c`q9zweLXaZo6Yo zbPUK$IXQ$U!4E!$WK-^lo1%!ABuqNrD^}p-g)PdTv^}_B9fyGDu*5_wv&D|dNn3Ni z{&JJ>>C>{x@PAaqS^^)geMjci65saM_jqdYWh+TCFRqKJnm4tSL0ri^rLuf>jN3@& z_0~dw9}cy&D9HVTt^TnEkk)p9(s&n?z-sYP!SG(M!t2CK{5;gg@j)b(sQ*j}D#pyU zS|)OaxJaxXAXnxh<2KauT|R#MQ(mP@eaeVW3u1$!EaSKmiDyP% zL7Ero<$@xi|3=sc$ZWZ@Oo-I09=6`958k`1TUYLrfaKrO;wd8?l-^l*$f^8mu?nH5 zSJux*L|&ellcEFcM^Jjedd8TL^Al-OV%b&St!e)yry!NL=}wFvV3(O(`6WvgQ3!us z>pt>{>U!ZIi=x*mnSBt+yY_|)IGs0Pa8V=*Hv2A5jx2QgSk`2s&$DfPc~ECcn0c4_ zD9b++^ME9HBY}C8p1E|LmV^9_Aj{dX{N$M@{k!}6nhIS`lU{;l) zouM#IED>z!YpvmhZ8_xHae-{nAgVTP=M-pjhtoVQ&jOb<|AIL|Z4K0JBxrW2lRlwFIHF>Z7e3g4~d0jEGxMI-xu;jNC*WTsqgQSjNz*5mn$qPch0Sg z-cT%;if^N<3&~woN>i*cs!~C*5D_Vl+RsK33smZ{XKCS0syWk4JGFOo+JzVWg*AH) zxGBY?1e5a3B=Ng?n(l&f{B@t#(@k|Zw5loFA-%PR#Hnd;T#{ZRFA=@S%U1rly1Ia| zb%b}o0xL7|QmAXPa4A6gxz4>|wf=ql49teVyMgCMsG2>LkXdfQb;EiV57ssD#Z% zuZc1FHbM4&m$sVO7#Z)o=oMyHyUEzhY}-fMn?y}^+s*KQKUrtG@BBr(2fhca^GwaQ zT?|=YXu3W4X2tfHOIvM%{R-;;%sY!qI`~y+q zu;1g^nYpqc${EQwHovLimn#1F{hyEes^wi6R%3-TaZ=kYvBHw|{k7RAp6K16#JkKj z`@@CM4eV|58k*zyC;Nz#9qPvnM(#S3jWQ~>>R0G~Hu!oLWajwgt_|i}k6;2%f5gyk zc7E%#4Holg+(m7gb^N)n>DJN*i*XVj&6!APQ`;N_DeXXbWmU-sCL8;5jXsRQug#Om zT%~ME7+6dcXibr|8s?gzn`rZ6e(CAxHm~8ny3uk-)L|EzNZ9XajQpjsTfJ3=NbSzm zc0W{K;J)RM;3z(e?oAd#c0!+A%bUusoo{;g+dko+c&IJ~6{VLl$I^9_$b9%|=>p}o zZp0MiDOndaQe554pmyHNy!UEHlj0h2X$hu#3h)JmV2zJ3@=*(JWKWF+Mae8VqsphM z`2D)0Fs(A@%$sAa`ooSspaM}yZh-uPvcth{kHTZ;b%Npf&WZ%xEMAwBpL>C;Boy5C z8}Z0b{#K4-x0TrVoa*(zl*wLMrbXaxW8-0QIU~@$0Ms1DEMDh%P=4W%wD|ltH|afj zur1P_-S`$;)|O-rN#>h{`(B@P2~5H_w*h1#kcH`zqd9sh)e-~lEMm59L?TMO>d+rP z8R(o7E6{AdhTe01IsX$>)HQ5;^EIsW^d`qIe+-z#vy@#jsuPi-RW-xbNfi!3airpD5y4YmC*wQB^8 zO?azGRI52Xwmkg{XWT7vZ$}SK_eC;e{^=Y1+N7ThO%%WBU5IZ;o zwMTmkr;~7h9wt^HvqI!&5Ym+la;8dOU|+#VoO_-E-rCZBxwF6p(PwDjX&{GaEvc71 zbr;i*W*AvuH!qG*+iOayQJGw)R9$H!C2L{x!#ue&E^w6e6lL;D@Q{-0w+lsX9gri4y;6Q8jS)JnqGuV#02&ImJ4Xt3Li0+JuWiPk^G3&lq$8jW=L@ zmD%G1kZ>bi`t*I~WY&1}6U}iYKJfEKbldO>XRU3%!umvcDj{8h^Pn8@CADW|y$Cfa zP5|}BEYlTV$@b5nWX`Abk{)dX#5XakYm{&;uW`3R!*k#x(2G0~y2!!=t}3vd0!wO^ zU7^2**RYQ)l=IN^40m^8Ai>qu_3g{&Gja{nL2_^8a`Cd>0dUcRtITqiu^Uig0FtgZ z*dk%+jw`uoX>$`y=uD5PY1B+}dc3X#lxA3_@*9B`Zy8i8LV0#~V5QNLL4Gwh9ugzE zZKxNJMRdJV9%@TYw#lCh$K-Iw_6qEfX01A)x1U^ch(-t*Rnp?FgXpa~mdi?WaM|W5 z9Q!NpO=b|qnbcloHqPXPRAkPs*rO-vVVe{wHw5Ly}GzUk}61H6?RXgzYp=kWB|b4FtwFZ-VbyO@&R@otn&wd=+0?_}*A}Gt?ng5;hg9@Q_iBjx_+=lH2Q3 zBi&Ir?WipVt(z1oX>$ox;fSD&D>IXvg4ce$!j?!jHsS7Kq3bL1>$HT$u7Xv-p7mp; zF9y$bVb&JJ&bpwlSdM!xNoYA)6io;K&v|Mv`<;;L02z*FBK6vVhql?L^%(=KBuXVI zIpZbLs7+}xWR+vd!A)a!C|X&qK%;F*`DGS(n{OxlQT+13hLwtDNEDW+dE># ziX{xAk|~#dUkA_2pT5|vSo$yQf?rdf-DblTu-*^pp#iOq z#x#PvUca}wvLQiYX9Ih#MahW6zB4zm3Qyxc>QH@k6c-4N07-vyYVSL##M1;bdnTJz z)Kz6PCCl~6bgYzD^qw`9*+kkln(@IAW4LO{DM9kNMC*%K*Yds>w!|e1aIR-_?7=x9 z{Zlw$su3&wr{PHRZ^sg|$`+QIb#PK4FoeF`A58AgPwPqpRV-SZJ3{|B^-9@7Rt5}r zs0Rmk=xK5ui_9%|C_wC^ZXF^FedgG55}?OQWP_(g#f%N<$27oFnkiMmgR!VPHI;!v ph*JdgjHLX7(^S*{6gxz4>|wf=ql49teVyMgCMsG2>LkXdfQb;EiV57ssD#Z% zuZc1FHbM4&m$sVO7#Z)o=oMyHyUEzhY}-fMn?y}^+s*KQKUrtG@BBr(2fhca^GwaQ zT?|=YXu3W4X2tfHOIvM%{R-;;%sY!qI`~y+q zu;1g^nYpqc${EQwHovLimn#1F{hyEes^wi6R%3-TaZ=kYvBHw|{k7RAp6K16#JkKj z`@@CM4eV|58k*zyC;Nz#9qPvnM(#S3jWQ~>>R0G~Hu!oLWajwgt_|i}k6;2%f5gyk zc7E%#4Holg+(m7gb^N)n>DJN*i*XVj&6!APQ`;N_DeXXbWmU-sCL8;5jXsRQug#Om zT%~ME7+6dcXibr|8s?gzn`rZ6e(CAxHm~8ny3uk-)L|EzNZ9XajQpjsTfJ3=NbSzm zc0W{K;J)RM;3z(e?oAd#c0!+A%bUusoo{;g+dko+c&IJ~6{VLl$I^9_$b9%|=>p}o zZp0MiDOndaQe554pmyHNy!UEHlj0h2X$hu#3h)JmV2zJ3@=*(JWKWF+Mae8VqsphM z`2D)0Fs(A@%$sAa`ooSspaM}yZh-uPvcth{kHTZ;b%Npf&WZ%xEMAwBpL>C;Boy5C z8}Z0b{#K4-x0TrVoa*(zl*wLMrbXaxW8-0QIU~@$0Ms1DEMDh%P=4W%wD|ltH|afj zur1P_-S`$;)|O-rN#>h{`(B@P2~5H_w*h1#kcH`zqd9sh)e-~lEMm59L?TMO>d+rP z8R(o7E6{AdhTe01IsX$>)HQ5;^EIsW^d`qIe+-z#vy@#jsuPi-RW-xbNfi!3airpD5y4YmC*wQB^8 zO?azGRI52Xwmkg{XWT7vZ$}SK_eC;e{^=Y1+N7ThO%%WBU5IZ;o zwMTmkr;~7h9wt^HvqI!&5Ym+la;8dOU|+#VoO_-E-rCZBxwF6p(PwDjX&{GaEvc71 zbr;i*W*AvuH!qG*+iOayQJGw)R9$H!C2L{x!#ue&E^w6e6lL;D@Q{-0w+lsX9gri4y;6Q8jS)JnqGuV#02&ImJ4Xt3Li0+JuWiPk^G3&lq$8jW=L@ zmD%G1kZ>bi`t*I~WY&1}6U}iYKJfEKbldO>XRU3%!umvcDj{8h^Pn8@CADW|y$Cfa zP5|}BEYlTV$@b5nWX`Abk{)dX#5XakYm{&;uW`3R!*k#x(2G0~y2!!=t}3vd0!wO^ zU7^2**RYQ)l=IN^40m^8Ai>qu_3g{&Gja{nL2_^8a`Cd>0dUcRtITqiu^Uig0FtgZ z*dk%+jw`uoX>$`y=uD5PY1B+}dc3X#lxA3_@*9B`Zy8i8LV0#~V5QNLL4Gwh9ugzE zZKxNJMRdJV9%@TYw#lCh$K-Iw_6qEfX01A)x1U^ch(-t*Rnp?FgXpa~mdi?WaM|W5 z9Q!NpO=b|qnbcloHqPXPRAkPs*rO-vVVe{wHw5Ly}GzUk}61H6?RXgzYp=kWB|b4FtwFZ-VbyO@&R@otn&wd=+0?_}*A}Gt?ng5;hg9@Q_iBjx_+=lH2Q3 zBi&Ir?WipVt(z1oX>$ox;fSD&D>IXvg4ce$!j?!jHsS7Kq3bL1>$HT$u7Xv-p7mp; zF9y$bVb&JJ&bpwlSdM!xNoYA)6io;K&v|Mv`<;;L02z*FBK6vVhql?L^%(=KBuXVI zIpZbLs7+}xWR+vd!A)a!C|X&qK%;F*`DGS(n{OxlQT+13hLwtDNEDW+dE># ziX{xAk|~#dUkA_2pT5|vSo$yQf?rdf-DblTu-*^pp#iOq z#x#PvUca}wvLQiYX9Ih#MahW6zB4zm3Qyxc>QH@k6c-4N07-vyYVSL##M1;bdnTJz z)Kz6PCCl~6bgYzD^qw`9*+kkln(@IAW4LO{DM9kNMC*%K*Yds>w!|e1aIR-_?7=x9 z{Zlw$su3&wr{PHRZ^sg|$`+QIb#PK4FoeF`A58AgPwPqpRV-SZJ3{|B^-9@7Rt5}r zs0Rmk=xK5ui_9%|C_wC^ZXF^FedgG55}?OQWP_(g#f%N<$27oFnkiMmgR!VPHI;!v ph*JdgjHLX7(^S*{^IuBoUZ!>ztJ+yH>>5kDgNeXyRKO}1Qt8DJoL5nGyNs7Vb77r-kIA> zEpM?t@$+5xw8R;I?~8{gT0Tv)JaXN6M_}<~jVgOMhfb09{1GPw`My>L*G)v5E_Gi{ zVNwMxz9IY4V*_!Ya1KQC-&JWF0rXsR^NyONitJ05-@bg2^ysc(@=3^jVNf{Z7>M4S zYr_3kzT56X$C6X;!0?lLdGaC|)G;?VT|Uepf}_d^M&f@oDEE}f{HA5aby6GSQXPko%AtB8b$y(jO2HYUZ{7y&zS3IVY8RAQg>nzj(xg7-k)0ZbK z;T~eZ=Q^PTw`B|^5%KUQe~&~GhhJO8r%7%-r@BP#nI(z!tR09M&s==_@RvO~5u`Uv zUU4vXgxb|j*)EN7oJ~b~x0^LqZ%q;R)$P^5cf#IGh@qFZaINFRFyF_>poVH=G9qvM z!L=N}IHa0CcJUpQ#moXxG}KXxIIL8t%bYoIaG(ZuGuNi%p+9xJ-A= z;}7!Y@g{a97LAv}(X~x99u@WkRp$ZAWTXVPqKmHrbO;Qssy<^eV1t=^%jN5bBFM&0 z?*}`i8#g<)7!!tFZ~4Hr$03Fl+}TVOoW`CHB65_g@!j3^xo|Go|DR+Pv_$ZdLgd^j zR~A(0ECW*(Riy*c;%y>}plPUcuZL~s8&tfKY}8V^VUZn1ZC*>p>GcZ)?-=Fa_`F+LhpP26IAf25nK!e);acrbN5k1on0=*9azeQA zo@jwvq$}6F9a2;cK3Cw|sWwmfbKrNY;U4s!W!VakoUPM(*11pQmD0k%x!g3krG0Hr z>>AxWptkN0ES>YBeFMEXUhDQ`WTY(GQ3+;pl<{s-hCo%#Q@u5gElKQm8tG4hL+M93 z32xA8czy(xB?(XriO9DO7)44y^{lZDxSx8&!*b^xwPc~mJDaN4dcd0NO%_Tc%j>| z2&gw`+Kw{%YJEoxT2J-o;(RG#2ywG676fY(co}Q*lso{0Z#-3_=f8V+J+)a=d9vFxSF&DecG1~dqL}b9q!w)7bvf<1YtHdxwxI=0IfdXeH3c5WkKOqji5qKU=WQH@Ao>@ zCf6`ATcpH?>?-gA66oR>S-o0&>-_T*+I3zyV73e9xpVf!&8%(8W26qs2uF!?DACRj zOi~7jz=R}L3G&YDk$KSfB;xZj9kVanW_!sW;6F#h%JXDfC5|E7&*Q z{e7ZA%~C2jfefaOd|yaljDZQUkXHOMVxHWbB!}R*PgTxJEC9-1!C>55W|17t*hv?c zzryqt6pjmmq3%%}VI=2&7LypG_@?e|N>aqU6uGZMI?4jc&(zM-@)W`>nHwFODWdOp_OZnmtH@^LRacZFMr-@Y+HEFUn>54;ZDIAE#j3#6e^$8%Fax|Rf+2{ z^JtNli!f4+S-je81azTD%#zg^k6pCL73pCWRnH+ekc;A?VG)lN2Jy>ji~a{-RGBs6 z2}`33`($B-SJyP)G0drRRjf1K==o(9fOd*M4R!PiaI4$n4$OUtxrdN?ySJX92NI0D zT;2Bd$ypaMH9Ge9PMG%~jpqK33}@b(eUwKLq$Yf*2-v7S12C^U-R=ri+7`60CqP@v zazh|lxvMG{?N{ToR>&hnSU*Pi)glxd2aj@Ko%@dSbfgE{X^lH19xLKjBcWDnABe~U zu%t_UjWbUY-~A11vNzS<<-M7b)wFbSmX;@=^%yB`9uN7(nmk7>bD%L%`oBNqv5z9g zPIrNWOE)2HA`4+ycdaKxLea{r+E#5Zg0Nsz#n?Jbz>m^-FxmqKc39_oogXA~N#e6G zmo1BDW)kjvK3m0M=NJ{+=$p*da663@4ky~ozWlzMaKsRmeh_g#invdjjIP2)fj#gr z`V3E8xsh{!Xhoj^z@@W&c#ZJK<%Pi2wa2V+Wg+;HQ|QCr4a~jO8o>#Rzx3kR$}#L^ zrlw+9d$Cbe@P>*cPFQX#v4Wc~vC7;fwPIz7kjcnDuxqyDIV~%FRMb7I_>~34 zX*Vtl2K6`yt3z=Sw7&7jn^umi+g6zmponhpHK;HEU@q_F&MvPI#+Pfjv+x+$MY2pb zk&r~ril}sp?9gP4d)P|1#m+9%jVn?W&aemh9lTZ*q&!G(0=&SqQdf>)FApplFBs9J z;mTyz!d(1cB{PS3XsCC&x2TUQh7~4WtTRHVhkO6K|Eh`B51?lzrOP=+MKB=NuHCh` V$L!8+=+6nl=kT#Z6$ie(_8+|9#QgvO literal 0 HcmV?d00001 diff --git a/tests/goldens/mon/psp/06-route-one.png b/tests/goldens/mon/psp/06-route-one.png new file mode 100644 index 0000000000000000000000000000000000000000..b106f9b8f6a105b221257c9b21ae4ff996111a7f GIT binary patch literal 3018 zcmd^Bdr*^C7XMsZQ$RL4J_4?U)=}9M!^VN=+NKf}A?hpidMs3|32SKw2!FS#}e*!Ct?%1;eR#(#!6A6afNr{t+Fy8FIiMHji@-f;{4*HWPLp>(dMbS1;}H zX&d^My~%6QvbR#cp4S+(<;#U_`{%WtICvRm9KwxY?U%uiNcBzd%;AO*6ct(}4z+x&!d#Lv4=n2@0>r@mf?~E{H_Nh__)H4_ByHA?4ILw8Q{%{tj`Hgem7Atto!R zVc;vwe>y}ZV#RIdSRrUv$m?EiLG-?HcFe>vo`Stz4cWZF}u-J zLFlU2u$Ur+xBf^Ih$Nxy`|>=B@Bgxt9;$u@&9&`vuz5?+wv#;BmGk|fZT3LI0$ zj|t@$5^LqflfC!X8i+Q7w48g4CsS$px<0f2L%CP~9)E^?uNMA)KreHgAi`y&aiz=y zZm5urf2B(NwHktynJiOG=8o6o5M~$DA$S=^HoSPa6jZYqI~rhVNIgkUZbZ~HB0Rs} zWMp4iMwP4@IzH!}{m29^eh0opSDZ^{8RBlyd3;j~u#UC> zX50fpv~;dM{Vy5AHig@DB~?PPeM7&^-usE{=MX;o^A@}%+vFsZ;gKy2_aNBO6JFH= zGZk<$(qv>CurqNUmP`}7xJk=OLng>GwNpI5bl|EcT*|Ck=j4WElurbGN;RM!pc`c9kh|_v8C*u`)5GQXrUc`}quz6MVxLrG0&7Z2 z&M;N-MH4&lBtQd8&RwLQ#NMA((M<{DEJ{Vwzeb*)c~?jn9et%d6$4b z=G%%6Nj(CTb0;FNFM#CO$z{|EsAm~_w6rwo2+xr>=28+ZiaxKRQ(A$ z%U=vUWm>khEc`8mO^kQ&i!-dUaRIkouEk-lwHR7Spfdbh&J;pAy$*L! z9^t5VlW<~4I`#Z3V z5f01lID56T2c)!D4O0rZ$G6rSBuUNpGcE_`{dSV}#>4|7fH&IZC*YJ!x+ zk=^(c07g~%O9}rV;&O-Mm+Bb26v|9#-w*{LHo)1`|G%_!a7!P*9!vf#)9Knp<2@7G z@0FS2<-45({k^?^h)#FWkQOj)6xlw%x(}XKeqgv4=1trW71}Y9AfWo|9QpEoer%Y* zu}-8#yfKnI*IO_fVlOI82ellW0B)f-`3RckyuZ-C_HmGyJ$4ZuO1elAsVV9UK^Q}v zRWH}S)C=RuP4GC@+1X(q%nTUajXQT8pNCJBm|0IRe4scULw6X2 z8Ykfwq=Md=UpnmU}6Jwri}s?=d`U>)&NSq%y6~r%VX0WJC;UwSr-`$QEOa2d%*HMZSJ`L zknM@WWzt_AK+Yr;X;Oh{Ok};0R_z>Jew`E0qqfNvXvoexIXn5ZbL^n4v3)Fxe_^Ke wS#OA^Mk6(0LFXDW<;8R{rso7FDoVzc*UI%f&$yYvzzs$HJ!W(5rcaOm8<7k%D*ylh literal 0 HcmV?d00001 diff --git a/tests/goldens/mon/psp/07-in-the-grass.png b/tests/goldens/mon/psp/07-in-the-grass.png new file mode 100644 index 0000000000000000000000000000000000000000..33dcbe5d6ae18d944370ec708ac7357cf9fc72ba GIT binary patch literal 3226 zcmeHKdr%X19$(KJqE?J|*0TjJC>0S}1Uyg>k~D|~juR{qsUSp)2!ap=1j6Rw7_?VW zQ4#PFl6ag_wI!Ye!ke=UC`6Me)d+zgC2{c)f(aySAmla=(6OD)+|2!XlgVWE@%!!f z`+0pg-)@TxwO{102mk>4@UW0*0GRg-`7T}XJLC+s+FOSFtV{}v-UhuczB0sxCY2@fG`&+k(W`25{T zI?wLem-WHFU%uwT?xWrwAxk^Agt?zQbj77``}LFILyrp2yC3z4aXHq#bXWc2HHX87 zT#oq=l0A|_wynDVhrcZ)99y*9Bl={PLoy#ElXG)Tdb!@OjAL2`f}c!-7mTzQkUrDC z{h;9)zMWDnpS;t{6Kcs+lcpj3iB6?}CUgpDgsRe#Ok-8?^mVbk1n1*J;}6@f+#k|1 zruFzmZDoE`^;0k4h%^v36os~XH&4~1w?9t|${q3v@DR7@g^tKM?Xa_b``81#DQ5Dv zLSO1!l2qduIFP?StJMIPWZ)|_nP5LvG|?ZQ$h2E<_=PH7DxTT*xvtRmh@3fHrK@=ww!%3Bo^S_|b&ueq`kYN^ho5mI^`;*s+pX(O?lSi;Pr}=ZBluq9rr*+K^;z~dTQBkBFj zx(=U5)=gYEbz2{b8?Ylq%DZ+nb?4)3hF}oq|L4RE3CzF<%{;CJCAVBaNt1_KMATvi z?0&sBD&wgNK9v>lcF}CzxS@hX7uCen>Q$e2%;8Iduh;UR_&l7`@gZ{F%&rSgxaJiT z1~Z@{OwNid-B~bDfTH;f_ejWzG}!&b-72(*j>VJJ3rEhri&i<5T@4k5gGL>9-i;J= z;~@)EO(+$HZ@p~=%+EMM7pVxO&+r>YgykGgMG&adn6y~D0A>GgLND|9@?Pe?g}>64 zxPd*@mHw_QNO0k+2EluDac(FLJgiMC$5=Fl9C=zz;V-I`YpiIVszDUi*ojU3!g`#b z=&5rz1PD-`WV8d9pyTe@qT}uio)w!2+lv!JS)=PMt$MFeG z(UUQiqyd^gOIj(V8;KV9E=o7cEICxK>GhL(X}-~tWPKBoG;_Eoqc3%z)N^@>l*XRe3S7&2#Q-xhe?t-y zy(7a`tCx|z+ioVcU_gmRw%?*@;?=S`|3~a6w@?+Ekv$HtH(vf19I3C^co-^DZoIC% z7y|RBOVOjO%M^psl(NKR5a74^sO$E_wS&&^Rx9zT9)FS3*1ZrBlZqq3C7U>)yXJ&56w2n%%x*}~H5I@#)(Oqwg;;lF7qu5K@$K+KIFQ~YSL)|&)$eT2j;C_Dfk`RxM-chh5aTmjTWZ?(=29rt>) zqn;bOKPvqxQ)JeE!Bs->wJDKn9Tee^<0)R<4JnY=gEiWkk`!^`>C0kqmA&B%k8wa^ z8)nO@zu7Stg7Ho^7kGLxxYOH33C}guzNy~LRt6k)DCVY!Q0TmtGyUzI1lMK`)-uQ5 zarMN{C56cFP)hF!-h3D;n(DR8=VY|Qj=kthuD%wVD3!7I-!b4nHn4ww_SNhq^m$IV zd89;vc<`Q8+wCX%XEQ970?$`k@GI2Ud}G588BwznPKek@j!djWbA8Q)gQr*9*GPsS zv*U=}53MNVaJ30`bGG-J8tD<|8R%6CrP;;2AQbkJIS zD@ket*7~V$$T+oO!uD!&yEoMYq0p;K@Fx66mY&#JZEh!R0*mzMioAkUjPX0KO;d|1 ztM#OrdW)4_BKp=uXp)%K$&{qbKCU#Mp^%BJAPGYuS^kO2>8Xly<>t4AUS?Ub2tipL zU=43i=L#f*2eYkw#^E-R(Q&Pi4Z+7@vb;LfgFYJxmD&vIIq$iCZ=-%|F`t^#daBC5 zyKT^&VzUiAf}jkuZ5(JGBBNh}wPmET5(!f!Xseodicd=cCEstg_#CNfu+wef9B9aN k{;kFTZ~4Dq`DNQ0x0M(6oz8uQ{DA<%w?u|i2k$=mA2N!O*Z=?k literal 0 HcmV?d00001 diff --git a/tests/goldens/mon/psp/08-wild-appears.png b/tests/goldens/mon/psp/08-wild-appears.png new file mode 100644 index 0000000000000000000000000000000000000000..06c6e987f9d9a04b213a7ff615a74485c89ef64c GIT binary patch literal 2868 zcmd5;X;c&E8XiTc1`&5EElcZFi)?ZUBC;8e$|j(41u-Hj$`&9hOTaLJs

f&9y=! z3KFFdlo0kUAqj$j8-W5bnh+2v2@pt-kVG>;?u=Xg<2m>KyJyavIp6oqyFAakeHZrx zcp0HgQ2+oK`FQWx2LSV4!tc_B^Wh!oKQ@Wrk7b1SK7Rl>vmO94&jWxK-pU*SfaFa8 zFm?n0wq6GSlasm3y>9SdKwJSW;F#`Ch1Bv+#bbp`p)r_uK5eQ5EIog68cus~=fd9=rPZnDruT@gKiF{Owri z@n3DXqpqGEld;g(tS@o&P6w-u5+J!0j&_pQ(^gpUCiZFz?%kvt$Jw zT(lH}(K~-{ZPC$86VShTx4Z;er$3a`S^rtRH zWnPN?(A%--1C+A{3!Uy#p{*sNn~9~E{BY2(mh6*i=fv`73i3}5Z@c|o)*X!6$!Sn0 z<@dydbh8C>bNJ?9#A1}N6~kHExcqGVUKns>>V~5s9p+N`RhpTj`VfetgXCu$;#(UO zC{gDr>-=x$uSA(z_O=dr8RrxKUE|Gb;i991{${Fpr-w09vf@v=U^XkF*UIj29|BWLY_L>P1O)*few_wY9{V&F@co{?moh{#+MuL9`G6m|- z!mQQg1Xk%?8E;eJ!_y!!Sy!$uwDpGV?dxj#z6g!ia1^@XJgIpD_eL;`Wc(En*&~0! zk$^Is!adQCmHuSyw3u)p?JxJ_YFJw#^+%uBduaz)cLRy%;U8+B zU`?JBTl5`-7PTTQuS75XgKjBm-HQYOWx8c=48I}xj%ZAiTutr^ay5Y|&H}rd78fNY z5p`{b7VPJTRQ=5!P>EzS4Ad8+SG9&A_R!5~xwmErbyPCK66|%X?_&MBk%ha`S-Rz?bi>e~TC4Hk@pBU+2tUM@I8*Nq!lW7%wC$!!w7;^=et@1{JvgLcFGtk94aUFR?Q2-4 zBi0wRn{z$iyR$k(tC`||6PVDTdntEpPnUfY9&l~EIlj?s(iPLSd}nFHvK4P0j9)I)h7q)r{b z`92P-vbNe4_GyCPEP~P6F!0j0=leW_zMmtQn8`aoZ01h{Da(}1#(_#6rO1A^;I3@s^zPgBWO@y8T+G8phAfxOTGftTNqn&wz7q;fpJSvjSCnoS5Tz zZV#Lg!gLIQU!9m=**+AhOdDF^@_fFT`?Fe-F@oxXp>~unz3WS$WRJ*0g=m!npX+#X ze5iN)oVkY}#>~CZi)elMg}BvWMkFK3b2>v1jgy4s>Q&P*xGr&uTsg}n4d!6cDvvl^ z@^2IObd(|(P<*NpGcab*is*77`KFj;d}PrF-lNN`H2kDlrn0=dmt&G%CehZe{Mrvu zdJ;qNEq!6+9%5yT3vZdcSM4NY-0ujPMj+PrX(XQYb3ylY?e#Td7r_ z053iOT{IWIZ%eoCAU|=avXDE1?tbyrw4Zv5-#)lhn%=C3McOKSbWNo?{T6Lp(g_t% za*$qJNbU4>)l(rI?A+?<%g$W4*URLj$ys+Wd?F`?R+Qk`mDcD(>hR;p7t{H!47zwbAm?uuG zaTSg^LR}gklGA`wm8z=j9rpbgX?kT|N@U;#F5wit;wM+di@Kc%Df=-dfyqhAm_$bQ zNTrsZ=W?YY-c>O+x$H4g>%ya$Ix$BR+JmlBxJTcRY)~{ZyFy8?`_C1{kf2Wc&BC0a zyE;bBx|HDFT#$u8%ri;Cj`qEh;Ow1g41Y?Eq+8Nd?mpq`;rSk=I9PqK5mekSCT8!K z)P;BRzV##Sufsm@%P?(YMuj7UC3l@Q7&CGn&C0g})uo@$5-|f)=Xo_^JdozL9$QD# z+^$GQ!j23d0mlGfAXD+&~du`k}X~P>ef`x=vcTz@67L+6$oMahD=gv197HX&_meo)SlZjd1g9|w85$D z>j12^1q9(eC6io64^Kbl)8A=t%A~Zs!lDI`xoyYufxoh(tR1BS#g$_F*A=oUvG!T- zUkGdKS0(Z0y4H?|<>3MeqURg&`6sjvw6MVD0mqkah_V%iOod`JZs+XQ@ig`IPL`dU z!kXsJUJ`-*a0*d038U5cQEU0=y2HAF+J=4;=M(H23BgHib}`6Q=~hOH^>}ogAq#zV z2!e{>vNG$IH5<6)ryXj+A^rUPE!U%gq- TPm6=Ewt&yhfE|qON6!8SBNS+5 literal 0 HcmV?d00001 diff --git a/tests/goldens/mon/psp/09-action-menu.png b/tests/goldens/mon/psp/09-action-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..f7ba3825ed7b618ff12c513c88f3dbccf2ce5054 GIT binary patch literal 3139 zcmb7GeK^y58~;^G5}m_ICy|twN^fGsyry%IY8}0(gv!ydv5sw=nYZN0<2)x4Lxyow zII`s>Vx%{DozaXkvx=FwZ9SM}GdsT@xt`}a*ExSY*RJ2S>$<<+@BO_$pZjy)_c!U} z30IAEy6XS{&~SG<;t2pN-hAKNv8hIeIA%DTJ`f zqbIQeBmL@G{{oK$d&W}{=h|;=CQprPOVfEKSryw~1g~@3=DaE)yFk^akkWFq;u@jD zh9vJMvZEWqEFXHruUQM*<-CFaFlI2PR5dzAa=rp?^5JpML#r{Sj#SJbSB4usA zL}AOu9Ew<1-|$ee%IrrQwJe>AEy_M_<9$7-_PiQGaP_mvWj}_lV4;Qe`d9nDlPUPo zK$kbRoh}xWPhXb}*r)`$gS+;kZR#2n=cW?dI`@JQ z)A3!;);|~?70GUGT9~?mWk;wdPVR?AK=d00Ub5m>!G|6Flv1RlRC^}%ixn2~QDi!r zp|=+#@J{~q%JJC}n&OkkKT-`AWCj#PREOo46^o!stPN~K5NoD841x!Nwl;=G=vu_XW<>0K49k+HoVwLkNNN_-wV^Af zPGjC5>)$h=sLd&CPUabW@j_)ZX3_@c(d{o?rFomD%P>**gR&hL=EO|2Sd7nEj=M=C zV9*MdS<|D3q(O@5bzWs*M!T^OIc%sH#I@T70%!A8HFDgo;ePV&mk)v*=GlDG4tb;g zg`si`M7x^qIcIV*=bqh+H*{Xh^3jt5(iZ=;sczfFw0tS_U;)nv-Pm2#huLgSTP)|* zi{;GJ-vMIi7w%5Bg7ad=D93%0Lz}CNL_11bVF$ib5Mhu6L$z@Yp}nHe)WYj{i2m@b ze0kCnPSVZtHvN~?NN)0<$+yCbPJu3m0L{HX#l$!8pbIc~_|@9}s1@-4UyJ5{Gn=Qgeyjy=2n2R_1CW9#rHdTu(md{s8pL)HW=p;9K6F;KA zkAAT`1#A*Nlvfs&A1;pfJ)3Ec2=Fz7@Qi5V$hpdz!$V31AG0tIXL4)EH02D7XI=LO z?MhJK6y)A~`orD52PF>ZQg~}PTZu>m*$R20YJZRM;#6-~y2OGm+4@Wi$}Rj@S6L{6u=9AI zG`GF~n6UOe5!T1@ytD_xbQ8qni8|}TB&E04VRuH{mpqgPw<(5aqY+6Vtpwg;ny^?ELfCPPJ-Jl zHBg4>=#MUh+6Ho>XnLWgy<@4UDWu8uJy6*Cmd?DWp?2+*Yhp#p_qQ^0OH!*fGZ!VJ znVw%%G*VDVVpUDnFe>aI}x>vxae7fZP|I62U_H5ToqTYW#uZ;~pbOWBQ> zEa{}T;nJS16}WH_rIcKVr@TunfX}h*P**3Ae$ey@_ENJIbcR=05Iti=AJ6$~i|Ua) z9f%dG7H$G-Q#V&^<7HYWbZGC?YkrG{VeJ|mp{t(|%vHKiQHD-WKAKev(wQHJkQ(daQlzS-62 zBb)R#1@SF6nsCky(O|0oB&J9&t&GExcD=QQU^KBzQ~L5&0baL3mUn>_2#xVgeN;cH zy(&P3CZ-IhKl8MasrhKHFfs}}^5@|$oDB`X{jA~N#pYGXCl*`r%a2jaI77bl+!EI!!L!3j&fxQE<)aw#p)nd$trP!ulbsX!j%>sk7xzadh8ZaX)>iwa(%&7u7(b0O4_09qS@+rXTjG8M?=4=f zjXN%;**%bRWSy9ZsN<2k#9?EqOQQjohmuL`LPk)=jBM#nejhm}wb_?LkT38fg0Rt= zIU!g>a_mY&RJ*4u?qx8kC;{H)=bJcFE!(6BmYk%C86 z8dTt9-V+u#369FFiRl79BzY6#(Xm(?=Jcjrrq;&At)T*fIPAn-TEHaUL69xlG0+-G zWhQ1oC1h*vj8GCiSx7zleo1mhVVb}%HaF8Z;PmudS6&BO;Rp4j@mDHbr3ZYLhEn({ zQYF01(ps06uKu`kPGV22#iPk8d>0>^n^D+~suElg|)6cef=quV-Lll&Q^sA(G15h3%a6oDYtU8>zc)SK&bUeem@4w9r= zo*2{oTW0-DtlTeCY%3-<>!@J$xzt4_v6@(EtDd literal 0 HcmV?d00001 diff --git a/tests/goldens/mon/psp/10-move-menu.png b/tests/goldens/mon/psp/10-move-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..3055fd5e43cc2bf8faebc1611748f385db5dc75f GIT binary patch literal 2986 zcma)82~ZQ+7Hvla!NCRnD9EM)eiRJIq7W8gbRa~)!2#3&(Lom3M3%7GfXLu7AOb4G z8b&{$LI6Qf0wjJTi$cOq!ji-df)ElQCXs|?x)p1t=9g8!y1Kghy?5Wc_ndR@yVs66 z+bb%lDgXeWh;lgW3IMBKL+{45-$5fBZNm}hqvhk^>I49Bx&V-r0ssW6=!Q;SM7Lahn_Zru?Dq6Sy^=I#ML7=7gp_5exP5Ub<+qc|9Xm0> zn_REzqi<^ca>2wI$LlPL<pjR5TZ=mj%I!C3ngH)(1EU2#JTd+o;yhSb(B8a(Jt9%k zN|sUF_;8Qy)^J6%pi%WP>aPv%Z?c-O20==1tp;z8vt}PJEysw`JSS-&az1EOH9V9t zW#=|{ujxFJEW6fJ&z8QsVyrdIDOPwb{*}k(qbE0=Fz+ z3`*~6&muLK&P<^(Hz5)olVz2SbObo8l!j%+ylsD1at`*wP3>9maBMw7KoZqG z(M0w-lg7RWtJK|T7yqMX0C}HPdB#m zQ=M@8bvROJtyan9;D6cg`0}&vc7=t=9SB(^ zlj^HFH4!5!;g4Z_R??*RvaFxkD44)+@ z<9>RV3CS1L{p_vdQuF)*q3gzIVJ^BElM2G?w<%16k(nNzuo>p+t60}3>^{?Q@5B9Q6{D(r1gS}=6Sh-qeUX37 z2Nu*7ua=)`1XEoL_a1cg_#WMH0e{k|@{-nRaR)kcGD)78 ze|4lVU|Y~rpA&I|zDJ)VGObzBMWXUa&O`GqU;1%lkLXqzkWWPVA@~lEUs3mEm{eK_ zd495tu0aOH*#ty^`u&&gK7w$un=oRape*4Ii9VP%H0WDS?_(X&gjM^8(#PtoTUhYT zP}_||>7lMu9r+Y<;<%-$PpkL5_x^VlbN;~gK2X&Syb9J5m{UdukCu8MydLL#tDULs&}9ys#$ z6;AN;gkR{Zx)wcJ_^4ka=p9aidVDS`5;_k zP9tCs8eO_yO6J02^RDm&v+W(7a7EU!t#ZW4PQq2kIj{J9^W4}q|EB_8+r8EfTE7G4 zN=H3xx!bY3?|1}zD7Eh~w<(&^xS*JkYrW1NE~pj`$r>bl7>hgVMzg8kt0>*S;K{9* zl!*tHS%!RWJX|B7HN4tL9FI0Y>mhcj{lLy%2o&WFNx36gutN9Pax90OapMlDVlTCz z;OB+WA`s3*XkshAqlel?Jykf-3*IR-&v-P1WASEGvg%fpJS}3nur=#v=;2(Pbs|BahH?)i;9W1)9Cc*fk3+`C}?8zWQy38V{EKgY|3&E>V8 z#g-=8lLM1s}hi(GJ`mI@i0p4d}YC+zxkFC-Y_BsMj@7yIy|y z3|WSXlO=P*pB1lL+I1VgGV9uF4+ipn)093==ar8yqGE=G09e|8$u!7$t_%a$ zi>P-E`A(v)jY+k7C@eJa&t7O(QH#-Fo>bAheoCQWs5f3oL_J9Fn+q%oKEThOAKVQA zd`jE4s)4O#*b*MEgliM4={$ z5o{_ySjw4bj=c5SauV6nS1a(0D1q$^vWgMt8m5{T`N|0@%}2C7#UzNgIl#pZ@bhu8 zOFAghmbqH`|MhwXb0&>%IE+}H+@a5HLvq(;+0zPt)8{{N`bN*F2MO};!n2Zx??m7a uE@~~@bNmn7|Jdd;Js9k2fDWr*QSJ`5q=vRw+yyn%0P2YI;Yz#H$$tXc3y8@8 literal 0 HcmV?d00001 diff --git a/tests/goldens/mon/psp/11-battle-over.png b/tests/goldens/mon/psp/11-battle-over.png new file mode 100644 index 0000000000000000000000000000000000000000..450700c4229fbfc98df11e6299bf6cc0c79c283a GIT binary patch literal 3379 zcmc(ic~nz(7RR3>B~q(#hPGO2Nf$>73SyyUlNaMsaU&Ibicm;utt_n)F);x`5{ID7 zsJIN9%92=%v8R=E1{4Vds#MS*B$8@G7DWOtm_SO%VqUg+k0gLIJ!j^>IXUm-ocDgY z_jm92bHBfrzkL$BX~D}rF9QIuU~|ldH~^UQ1i2Q?`vY*nc`5ScL$h?3Pt8e7DUS334vUquQaNCh%i{d=u+rEoQ-zItWMoGY#n|p(U z;yjB2@=xE~__BANOqH0{_W3v40y1OnvEDR$dg1o3m#dWO7Gsl_&)dI-82N&0S^`BXWMO?qF86R_Q(UoQk8)0=4o-}Y z3~rdbB=<3x30bIJU@vbLanA4O2kBQjfbmQFwX>gzw`t9#ZD zpi7mzIGHwS>0N7gH_|GrmG)UboBnQKi{5aC_W@yUem6!umnGAcbx^*=CbFZvL)X5~ zk}Z~DI+w!EWLt3hh1tl8#t{RdL(TO}I}N7oIvuodBzWrA{TtcxptFY7ABn@74-^n} z9CB*lSDrFBA;}7=vZu4y3O}I|f(m%uWiAu?KrY(f< z=8n|GHM__ew$8Ass%#?r%LDP>G&mGhVMwwZU6fZn@fR-al~X~sXqWA4&7#n(J0HQ_ z>Ru_qucGr|6=R`OOFbd13dei8BtisQBp;3Co@r1&;SSFGtAEw zNS{$+Vg8#^HTMA~8qcHRO{HkH-I)GxmJH?_2*NT1u3}IXq)YFA)%K&BTdKe;kiE^4 z{e)a)R8v<5M9gUBumsk`vs0Cgzssxj>xJM?yMm}Jd$4Iz%Q2hcBvMlJ_g0}NM@(%= z3f%@pN!Mj9QBq(@t!`{ly^T(PjgF%)?dX#`n<|-%z*H+E1D%Ml%w$rSu#<$r?cF~ z+k!(o^|duv_@H@rp8ZMN=n>wS!qg>U_^GVgb{L(fViYt`m3&{?wMQNyTHyXAq`6~k z0845+l6d&Lw%mFRoqdtiW!l4XwRQ->73P~th}U^g4AWM%%wyMTWDk&28TP(Wi}}G= z{Z@ZYqpe-?RknqDS%E5?3G*9I%TM+7ia=Gz8?ozKvS7XcF=5wuC1*`CMeCve*{Pf} zJcOd&v6*Ru`Ex!a;%Gy9q;3)gR~PN-#56vv25T^Z+@R|;ONQEs9I1(xj0*)Y)mWd` zzr&u7K&?klV4RWm6pc|?0jXsTAj&|IivhO?-s8}~lsC9h5euyk6UQA_T^cgp5#So? z53yu-ch9D75`}YmzT7gr;!a9bUs9zei<3~Qlgf-WLTn;VZ_Vwp^?mM^3hmkhjF{xs z)7<>R25TXUzE3kJDu#XB<8 z*a(ImOkzzMkHin%6V!Lq@2A6jMyHw!W;i<&x?(3j>Qo`5THifw5skMY&0AUqD?UOP z@;jL5jVo%Ot2xg#Ev!K4qLY8_lZuDkl2NW=VVo8WsBW^~yVY{;Kuoi7G?UZl^F)$;ty$^k$tXDx&Ak0@kuLiY|MZMoT1I~Nahw0i3U!YQ zyU>-FQ}SY?u3W%I7C3z-+x!xO@UVd}H#7!^JTBb1bm?*Tqq)QFQ#PNjs4bZGrD!*b zfvY(DT%9+!am)3pXxEIi=IIF`?$Ah$Z;s9gyAny@nh0cP!N^RwSZ~dH6n^W*Kb*r- zT896Gy>d!r`mDlwrpFU)(F-U27G&SELn|=iGoWf{#EHNQJNmyD`??lUg;(Tw-p`Pn z7f;@6j}kv#ojs+qwu0-s%~FXmAg>%ci-dBXPo<~VLj_)uP>w{n0zFc%W+8@Ut*}}Y z8KoaKPCglr)iSn`(7E|~;-jc+bA)-qqtg0I|MFIkN^115q6D?l$Oz;7!-?{QLzaZ& zb!#+|snuW_y85H4Wp^8@bZQBTPv8J1a24K<9D=-1Ja|~lqi6o8hC6U!vl9msgw@%5 zzFtFEmB~`}b~&jlJ=Im8dF-OycJhwv1p~Q*oYmS0=W9SB7>Q`z47mt0s}RpMS4}zx zB%!d5oywA}?3_Ac>T+q#g??Vt8i7^h^8De8D4)!kTrWmO`G!qV%UAsmxn2yF@?kez zX&}^wI^9Wk@5OeEa{o&exo-->pFP%|ZTj81Ud%N+lmS0*FibM%y^UeWKO124#@G!t I>%PeQ7a|DY3jhEB literal 0 HcmV?d00001 diff --git a/tests/goldens/mon/psp/12-second-encounter.png b/tests/goldens/mon/psp/12-second-encounter.png new file mode 100644 index 0000000000000000000000000000000000000000..2f06bcd8014863b11263dd338482d083ca4c2f32 GIT binary patch literal 2933 zcmd5;dsI^C7C)nw;pDBfnJIJBvDAE9X1*ayM^Tf?(9BTBe2q<7Av&m7SyS0eO`(=D zBD6{^HR$>rL8YlF##A5;%?FT{f{0Lv8Y*(nnZ|Wz&ANBpd;hzO#p3XN`|R(x_wTp& zw+r@#1sWQd7ytlZhz#;S001Ao0>5il>3}QZEiOagkHyiT1A74=+Zq7QoCkn;aOsQ$ z0B|k_i!3zYn;vGCm~GAJ9S{YFkwy02nMG{rwJpJ2)lScK7qBjrwo# zmUZ7GenGT+@+tShuMaBpR=~bT8~pvjwYbd=Mq8eI8vhmjx4KIKmoGX*SVkYZ6kr#X z|KM6SekHzg6*2YW>uYZ$+J8TArO-XyI$7Q|tmY1j=mvA-98Qb-NT)VWFJ%b%lvVk&fZo;Ik`NRqWC|e}MYYZd)F3YkkU@eg9^g(2~G=$KMp9 zDAZL-Ml==8z`8Ak`Vh**BdYbi-=% z#t$7sWJaIVF6fb<*KZqT+(&$IW6LulKDcMNp#XQaakdo6F1vJsq+=8WBC-zRMe{|m z!sAVgd1-iOvyz|m4A-XS>-nO4WRxoV-tL>okR+#$l8{8PvVKRW+*o4ir+c0>ww|3k zT|^Bpznst_^XCwO`p9?bvmg^(=uZUHnJ-oN6@q}4 z^GJGi)s873`%~6YDG*MSXp8Bzqg$jEX9T-O23e-wnDpb0%nOd?9Qw{_jl9gMZH0#i zglMBeD$rg^v)+Y(pu|^s01_MUtYu~*u&CYQ6KD58=bIE$nVh5sg|P?Q4j8ijlht4N zOMU2>GPhFir48ird3_b?hENyy?Am`Odsz#&wphdC0*~1^;cJCtKK)vTiUu~GhDs<#&b?Hx0Bvv=gakSLD8)srhcRc7}V=n_efB z9VrCaMe-iXo=u-5@nxVe0(PId#c@*>GNqa~>?4Q?5z z+erkEJ?`SyY<#lb*l^EG*6PV}oNuLol?E5Op{Aq8jum&+59I~-rP52Nljvr4?bs&} z**lA=Ljx@nKc?qqj=Y}Zz@xHX*X}|#nUHP{aqVJj63UfmMtSu1q$nmi29@&(=66uR zJdN}YsH+;W*;%8hJAK9O3)qYfAFHDmh2WL{O-cvc)H`E84l6tm#PwOV!eys~K_D z9-auUH`9peo$Ojc6Wwns==(4sKh3^_gOR$Lxz}**BHzRrzLDJOn}I`3E24}VD}(=t z7RwpNf|v?qB|LOYwA$1Q)!`{wYRv3>u1Rj_CvF?6@|YL3(fZ5P*^m_M+ikvz%opC* z7jQV3yv65{9AC@u^v4WFVQr+EwFAodfkf(5Xj0*$`b09oP%1*IT)i)W}8(p zp=`&pyuz8z?<4l&n%8dtg}thAR1Z`2g4oLow;Idq_Elw7%+xlgcFnwLBJ3@|b*P7- zL>)evAJ!)eb}u~TssMSeqnyvt$QvtSC6@^cB9V#b<8+bwagX!p3P|F78;LSZb%0dm zy*(?afM}moJ7%z^_B#m349qJkGKNMUzqQcntK8lTi2}*XvGUd(gNV~7QR!{!nd`1) zd2ioQ-_^NVnGJhlvOl(;r49}

F3z)$@hBWp(#aDig(xldR5IZOOZu6Td-kZ$k1k zBAc?E-p%~!HB-Tl8LHUNnD-W?gfYsdN({mA4;#!lhcnek@;p3OdR)RFit^;=;2ATH z0x|~5#yveUHqm>a)Nz8P7~QwWSl&=Whk8J2IE*p7`zj4io8=eKnA|$oiZ{oVJ2JqDMjQ5(nLD@X15OrTrso$;RZT4J-yS&4;B%oC_rFa8hV3<3gew)Ze8n^K zbfH^|)-D@^ZDKdz?!R>Mo&n=5$ZJdojPz$?V>lae?Stbm#po7DaM8qQ8U&U*h2z zPandeicJdTjkS$0#iSP8BPMBmF!Yr680kTZ&r=n74jX@~l@e3_>&48GkUWt^cs&Z@ zaX-vUEE6Bh*;<<1wehW{b(Z!60=HUP*)PZyQc;hMi`8^CKh~wjSnG(=ByS9Srbu3?ui5nqqE&)Rg|-*8jT+%oYUpmkWM*?pU4@ n{!h1|fLm2&z|UtO<)6O%HSFb)xI^Pu?&?t_gLM%{iK|n+WwIyHzA&>+I5L-Q{ z2#O03Y^CT3q?Ub4rgB(QNFHEqV&xE8hJJSP^~XISM{LjNcO$0zny@An0r^1Zlw1Suq5q zdP30iBM`Lh1_U`Km$ZD20uStt?%N#%8H}&;_R2yCTGkjGgo#KWnHl%`_Ji&Z>|TCx zU|sf-`2C)kHLKUZJNf9YZ&%ZSzwNm2wC~2MkMo0Xu5Sw47}m6`YggZquyZTIn)bx) zIPiYhnhn`Y;w~M@_WC5^UUI?FDekrhnSPmyxprIvmD$xTu5H8N&gl^+n5{}KJjtOW z`HW05f#M~hvoxtBeX}fftDk*!h71lZPWL>)zdjY<^do@=tPow zwL()qrNX@9y9eo8b3p61`}u|*W=y8nb48yd2R)=Kp!tBn24R#-DcMw6 zMve;5x!fDbSW_&J%V&~e?pDGpyjp;xoCPDTD+5wBvClf?G*Kw zh6y<{%3E=I-$I*QrB+w9EW8Mcq|PjC2uuKpNhP7GtKcm`z5Llu`AOO#DpOrsL<>hJ zp=YRbg+^wL0CqBh_hc$D;T?dhy+~!Y);|QBz*p}LLC+}qcp?>sCCRZ%yaKo=a9?iW zVhHAzRj!Q~aL1F3XI-(JwVc9v@{^h0%@od3IiTdUycf63{aSKhW{;UR#u4f_01E?6 z8cZ} zVBBaWUM~$K&FI!%lt-EK)pC(MQ1&%Vf;ZJ-2a3%!ObO1%y&85cUTUa*94i|`4&43$ zuSZ=M{Rv^NsQI5Gs?5R_M>>?4*SPCxqWW*9*mN^2ccF5_d_xU9T7M zy3>dyg+L;ja*1LqThVzpnhAjRb7ABD#>K|pVbvGokC`L|AV&J_3Iy?cTt~Wy;Yl&u z=ZOod_QHwQl>|MlTW+QR=pHSu=t~gYS38f}Sp|omJGO9`#u`5E&PF~XF}8oYo%y-D zh#gO|%E*La{u(yD>@>jOXTRtl`+>MDtF*`k5+ikCCT@*BZQ=5{-iaCb_cgId-_ItM z7~;B0WOHLGh2!W`mPef!;Wln&Y-=WJROI&dm7EP$WvMWdoEo+W#da)A(`m{V{R0b* zSAs=;60$BJ(TbDX^0DSCU|bJ@uf{gZ`=|RRyRlgV^W{vN;Lv4?aFnKv;K#YRqPL}$`&vH-34K#~>4D7es(!r7;=XX9~it|D~L z9(XL3{o1I{Vn-qgXF&D#nA78UMpGc+Vf9Lt=Q3UmI+6ejMMQtV2GRyMv1eP?GpQxdxCa4r(8o?Ky}G>+dRJ4}^2KYOdH!50qI+`=qYM;~Kuyq*qV?G;09> zV?#g>-6035dfj*5a_^ z=W-DuUCrgnJF!i3$|Bt%m-$d;867M52qjE?ZfzmrF2#%rb6G+>f4p+lr8Fc$+Vbz* z+V?lURg3lGuxJLT#z>^{y_=>c1Uz8q&O?$3|OhR+NMioI)y33?Zc&=|?5~n|pySZPrus8(LglC9y^7Ew2;Y0Yx`5!H|bXk@V(CVGuTcu7oP&1H-iRGs{M! z8NAjf*UVwsYYsymvq3cA4ohpX$xtqb^4A}uXocy$K)W5R;A83ssZ}qkPoRgJXlo*kj z05=cjQPAmZ?pmL6At&C;#EtQS|LM67u0Owp)rINR6!A#+Vx74cI32tTx~Ms6Q(!)! z1tXOyrbebIw}GpGN5)V)-)ya?CRf3_kD6REGDiQI`o5{Jj=XW~u{Vx|7riw%?FAFi zYsImQN-RW+C!d*pzT<+e?CRRI7Xam8OjzV}7w&ZMN!Q2AFLjzs0zCSc{Ovi;cx;bt zFk-Qgn`zpvr(yQibtDut$wdk|M!SWNH5 zx`>5-(gE*K&er*GYLrPntZoLhc?<0eBCUQ8t0T;q8_PMP>4lymY9G$kn7jH6paOCT z<5+g6e|5@1D!=n#^@2y%m@(ET&y8BzqE5-??^Se-5wc?@^*xQH<|)h#S&ExnwOcJJ zy;^Kr0e&RFo9&>EOaKBSvn^&Ve{4)afT;`XVe3Nv*d6`dr{97~1cg?2RuUg+$rP1bA{Yt3Edm`|XmC0;N$JgZAl3bR1^q7M~*>|6+s;zP+G;ZSpUD>yVUil z1vmeA95le%^7SiM-|*XJcV)bG{MD5K8#m$a&7Z$z&sas;QbbsHZc(eEE2b|xwTX8| z-BU;Z=6Y3Z})aIx-@r`f49eF zdaONfiWg-F*B~~&iI55~VGTna1-rVL+IpfTu;ZQZaMVd5?nAtknf)(S47Jf1m~~{Q z+(#)i&7jsvO|6O6(U8psgW-@7sdxSQ+1P;beo_xZ5$hMo!MZf%)U9k{%E&zL*B;!N z*Y)V$I6heQNaT`GUM(!Nq*%oB7}V6vTL+F3K9O=e7c6=E4w@(Q3P2*j&gOwhGOTXTLIy?b$B&>ZKAr&mwdITQI#Nr z&Q9Z#C8*EL1uOY^UR^n7d^}ew5@huSi|fTIRn&ic$%ZJe>)#~E*v&4%lbSY*UHs(q z(g3!yFb)M`1&+b72*Wgj1~(Nd>_9LM+)onxZpY;0jy*wWJBqC84D+e3jlc&D^)D9j z5iw$SM`V?qzJOO1U2jNcD&uABND}=+;!#4nt3*9pU^H#t@k2v@?)?Ugs}h@4A~Sfa zO?XZ;>8RKI1|$!l6}e+QEj=m`ab%~J6$0*w!f!sqjC_8N^`ELd!+wXv%Bp!9RZFYR zIF)fd^aLG{(F12cBH`LJo>TvlJR|;*JDTsmg~*I|BL@PI)^m_W!|*KME0I+ht*lS4 z`cLx;F%Xzz8k&PlDUAc8k4c{)*B(2wZa@T_{S{nYbNaj{uT8bRQf<&m&H z{bG6h`o)=U08&ug7f2yTh~JQ~DD#*#n!_%R*aAi3fr+}*R0MX#qVT>ZH#zBL*?Ltqx~ z2~R})&q5tRkEzvD%n4W)+_({ChtRYZk+W`J!7Bha@a9M9Opg6jr{f%Q2`kRSH94SQ zn2ngC0Q7sa53&5DGW7I!a&?GsB}n;%ZDqac;wnxPn@F;79pAN5J%ddK2fJkliagxu zi0~W+E>^RZ9DMN@_+K{o}^P)UNg^}`t(@|WVBmkcE5 zg<8c=igER=<<|y?l4X$;=_m)~u;d1-l4GOxIfK_ZAC7GeR*g0x1F@SWMKjeImr&!K zYJpkp?7t3c?B0L(FBPn%+a%6Zi>OEH;zvcgCM_KtWk;S9Z5u`i*PBgE6)x^w8_Bld zIyP~rD8E|R>{w%U6)0_aNUev$n0JM#foLTUo}dy_B7;JjkeFSmC4yvqI48Y2snj8= z2#K4p>FX+tS(epgd!i6u5hp`4T>@x^KgUCdV^CB z9?^B8X)7eR^QHTz@hd5;v{yW_7~faeR|i!K7~r5_syX*D$6+ZPQgb6YT3gF z^4QW7!!y^M1|v@D=@e)$7>*@dF&QRm6zjT+Q5m%q$C&X_vpB5Kkt&rB;%dHB+F(#% zt)q}iWFj$>QPw>U1nZD#4L>lzm>zQYT9L#5nx_)?Quj_|&C&_nj1ZK=``L$dxLkqFWb-$pP_Ch|`fttycz z#%pSwUA_IS?e@P9I_q3cD{Bt#9AGf{bcvvzz}L!M=9wyzpkro6PRx)a7KV072=sItR>FSeLu%SWUL_sB!ahkyMHd(g0BM44J;N(cA2# zo|})#>x4D-9Lwmunv1A5al3#z)8%wK23?6EE%vwRD5d07#u?nNBQwgAZ00*{5`S`< zgf$1#fONAi6wnpWT{3m9P9F@!=Ik-Y#cXVLjBmfvGs^Sa(h;fx3rK(u-=Qi7Z z+xLc;A)2nx;n{CsQB^AAH?kvxn!|C#qpj4zH0ta^iJ{|Lao|r-o|m^Q*dwc{dSiAB z2g%C;Yj0x{yuh`zeGA-D<)s0#^(0=ze@ENw5)XHT;IUD9?F1!-m#r$4F5%Hr!@%x^y6C3a39@M7HZcUJ^= zM1tS!#Azw9;KR6c(24zBV!KqqKERWd0*8_Q<4 zx7&aE$Cc7Q)-l`8EYLn%n2|d)uyD>M`_Y6|_8XtaRL6TUzw(-Yq^<9oKY158$mT!kCR) z4(*ADZ5TG|nUyN%j;KSznRJgca95O3Xc?PbftD2#)>p%|Za)bxN?>WyOQ}ySgI4z^ zRGnp;fG&P9IQc$oyhNgk4*=)a0$Orxi+EMcDv^jdt|-Mo&9xpYa%HiH-M}K;24+_% z^yC6637g%`PrOoyx_CX!D}UDEO#c9?F0?qFnpO_KeC{VdxRKc3dI2yuG&~&F4Nh#& zyfm~>7opixx+g50Sx0gg=Mk^DPmNpp7T~1q&ZjO1Sh*Ucc-B+vM;9&mTh*`Q&zX+TotgyXIc57#_l0=kEgFxS+VLge@(b_8$E| D&*oSE diff --git a/tests/goldens/mon/psp/walking.png b/tests/goldens/mon/psp/walking.png deleted file mode 100644 index 8463d45e49148668bf9556ab287aff0d5ba17d09..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2835 zcmd^BjaO1>8o#DVno2comPf-j`z~!q(^7$;47H{uCRXvS7iU1JPgQa8J*1sM~?4>$++0V1KRa^R60vvl#&rArs@rYp(W1PVEotYl44i$${=JZ{5RH4y*3JC_*eY0F<+XDMuYbbPPN4>yzM5o6er~$-UEWN$j4d>qDKXKamjH4D?d7P zn^B?2AgE@1Gnp3zbe9D61+LY0yad+^X0~=y+L9`LMU38&bU_cViolQn8$EEr8t@QH zHg%N0^Q_)LGk}ey$ByUZb(RT?yJS@5?w*J1%^NYVcm4R+nX~IbF0U3zi(_85Z$J0W zjd)VPjAuynW4QBcbb(s7?O{7R|ByIQJx0?~gGSsywqW6s#qGUdl>sOH`~!sC>25i3 z2OBesp$}1yq}`yxSHxm?&E~LH-c@AF2Karq74ZiG~jYE zmtA9!Hw+*YgX6h+Cy*;G8F%GkG$u<~D`{f}cCB|3miBxIZNPa&N%Pos+o8=@;dUGn z>3tOvF46)|b8e*iQdA-)Af1{*0=Wjp_70it5XbcHM!(JXOLpw4g33v%`)k8Bcefll~ShvJn2O1)rkYEd5BX#+)QZB?AVg7-82h@yPxLSl z^8H%j)@gtj;bD<--V( z=*+6f$_;`z5)XBxIM-CEEA?IwCl_HEyFR?hA7FlZQ$?&UlMHgjTJ~~FX8cbS zK)Z?2O!B<^#aQE;aSu)jc@Z(dOh>${S!;PB zY1Ru2-?JV?m8$w+|1G)5UA9}6m(k2JB~%cq;e`AyRK(KnSogZKuPAIyHtWgH#8!iq zow@T%d{)YKC;j?F(R96)pyMA^>93&3%gs!W!u?Mj$N5ECcaw8}$S1#Jc{lM7-B*af z?*;VvDnhL#>}teWN1BBL;r$4Ohh}=PC=B~bt}lAcir;#1@&Y9@u_6HmWrMyW`)o`# z$&KmhK5MiIce1k>YtuQiptmvpKE37Oq-m(*&%wOVfSzsYB-p!%Pr1ZP98ybqu1*gM zv%}VjruvHANF+2x$k6|6RySU<|GRsqo|N`8j_EsuC$c@U|85?T&M?WXrN@XEgZi71 z*${G>d@&vKk;I#f{2he76jrKLLF8+%vKvu~wY zn{WWw&P&00UlTCD)c1u>1{y!MU7G@O-HJc7ECNML&?gHYksp3yzl=Dj5Z``MOB z5eg10Z*xoq6PQQSIlNt#pjtc0L5;GQfyzMb9XRyL7uG^tihhz~IsEt4S}}jZ`1=fp zCW~z9moXd#o&~9XcVzP6F&q?ENLs9H7$8HZ1L#m<2K{m9z(=0nL-GIb@l%miak4L` UJe=|XI=KS)oe@C|+YcB12atR@p8x;= diff --git a/tests/goldens/mon/story.hashes b/tests/goldens/mon/story.hashes index 12ff8185..06de9855 100644 --- a/tests/goldens/mon/story.hashes +++ b/tests/goldens/mon/story.hashes @@ -4,12 +4,12 @@ 02-inside-lab f78678a045e60a5d 03-facing-professor 41c85940b3879e85 04-partner-in-hand 41c85940b3879e85 -06-back-outside 49606139f750e365 -07-route-one 79b314f6139df8b5 -08-in-the-grass 34f89a2914228005 -09-wild-appears 6c3c7da197d7a4f5 -10-action-menu aea75d34e0e13805 -11-move-menu 1b442aa8b93776f5 -12-battle-over 19e303caa29558c5 -13-second-encounter ddf25c14fc24837d -14-still-standing 3853825d77851405 +05-back-outside 49606139f750e365 +06-route-one 79b314f6139df8b5 +07-in-the-grass 34f89a2914228005 +08-wild-appears 6c3c7da197d7a4f5 +09-action-menu aea75d34e0e13805 +10-move-menu 1b442aa8b93776f5 +11-battle-over 19e303caa29558c5 +12-second-encounter ddf25c14fc24837d +13-still-standing 3853825d77851405 diff --git a/tools/mon.ts b/tools/mon.ts index 276eca40..be078d84 100644 --- a/tools/mon.ts +++ b/tools/mon.ts @@ -155,8 +155,8 @@ async function buildEboot(cargoArgs: string[]): Promise { // Capture-build inputs (only read under --features capture; set // unconditionally so a stale value cannot linger in cargo's fingerprint). MON_CAPTURE_INPUT: process.env.MON_CAPTURE_INPUT ?? "", - MON_CAP_START: process.env.MON_CAP_START ?? "0", - MON_CAP_N: process.env.MON_CAP_N ?? "64", + MON_CAP_FRAMES: process.env.MON_CAP_FRAMES ?? "", + MON_CAP_EXIT: process.env.MON_CAP_EXIT ?? "600", }; console.log("pocket mon: cargo psp"); From 5f9aaf088912c49c62095e45cc57213d4e2517e5 Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:37:53 +0800 Subject: [PATCH 3/5] feat(mon): add the chip synth, the score, and PSP audio output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The last subsystem the upstream project has that this port did not: ChipSynth.lua and ChipAudio.lua, minus the half of those files that exists to decode channel programs out of a ROM. Four voices — two pulse with selectable duty and a volume envelope, a wave channel over a 32-step 4-bit table, and a noise channel on a 15-bit LFSR. Integer only like the rest of the core: phase is a 16.16 accumulator and notes are frequencies in millihertz, because a synth that rounds differently on two hosts is a bug that gets blamed on something else. Octaves double exactly and A4 lands on 440.000 Hz, both asserted. Tracks are tracker patterns in `apps/mon/content/music.ts` — four channels, one cell per row, tempo in rows per minute. That is the shape a four-voice chip wants and the shape a person can read back, which matters when the score is hand-authored rather than extracted. Three songs (the village, Route One, and a battle theme) and five effects. The core renders on demand and never touches a device. On the PSP the host is a dedicated thread: `sceAudioOutputBlocking` at 1024 samples paces at ~43 Hz and would cap the frame loop below 60 if it ran inline. The frame loop posts through three atomics and a sequence counter — the smallest shared surface that works, and no lock. `bun tools/mon.ts audio` renders every track to WAV, which is how the gain-staging bug got caught: the mix was scaled as though the output were 8-bit and peaked at 84 of 32767. Every unit test passed, because they all asserted "not silent" rather than "audible". They now assert a floor. --- apps/mon/content/game.ts | 2 + apps/mon/content/music.ts | 275 ++++++++ apps/mon/cook.ts | 24 + contracts/spec/gen-mon-rust.ts | 17 + contracts/spec/mon-spec.ts | 32 +- docs/MON.md | 69 +- .../crates/pocketmon-core/src/audio.rs | 640 ++++++++++++++++++ .../crates/pocketmon-core/src/lib.rs | 52 ++ .../crates/pocketmon-core/src/spec.rs | 10 + .../crates/pocketmon-psp/src/audio.rs | 100 +++ .../crates/pocketmon-psp/src/main.rs | 5 + .../crates/pocketmon-sim/src/main.rs | 32 + .../pocketmon/crates/pocketmon-sim/src/wav.rs | 55 ++ package.json | 1 + tools/mon.ts | 13 +- 15 files changed, 1307 insertions(+), 20 deletions(-) create mode 100644 apps/mon/content/music.ts create mode 100644 engine/pocketmon/crates/pocketmon-core/src/audio.rs create mode 100644 engine/pocketmon/crates/pocketmon-psp/src/audio.rs create mode 100644 engine/pocketmon/crates/pocketmon-sim/src/wav.rs diff --git a/apps/mon/content/game.ts b/apps/mon/content/game.ts index d3273f72..d5cef7d4 100644 --- a/apps/mon/content/game.ts +++ b/apps/mon/content/game.ts @@ -392,6 +392,7 @@ export const MAPS: MapSpec[] = [ id: MAP.home, name: "HOME", indoor: true, + music: 1, border: BLOCK.void, rows: [ "#####", @@ -450,6 +451,7 @@ export const MAPS: MapSpec[] = [ id: MAP.lab, name: "LAB", indoor: true, + music: 1, border: BLOCK.void, rows: [ "######", diff --git a/apps/mon/content/music.ts b/apps/mon/content/music.ts new file mode 100644 index 00000000..a9762cf6 --- /dev/null +++ b/apps/mon/content/music.ts @@ -0,0 +1,275 @@ +// SPARKWOOD's score: original chiptunes for the four-voice synth in +// `pocketmon-core/src/audio.rs`. +// +// Written as trackers, because that is what a four-channel chip wants and what +// a person can actually read back. One row per cell, four channels: +// +// 0 pulse lead +// 1 pulse harmony / counter-melody +// 2 wave bass +// 3 noise percussion +// +// A cell is `[note, param, volume, flags]`. `note` is a MIDI semitone (69 = +// A4), or `HOLD` to leave the voice alone, or `OFF` to release it. `param` is +// the duty cycle for a pulse, the wave table for the wave channel, and the +// period shift for noise. + +/** Cell shorthands. */ +export const HOLD = 0; +export const OFF = 1; + +/** Pulse duty cycles. */ +export const D = { thin: 0, quarter: 1, square: 2, fat: 3 } as const; +/** Wave tables. */ +export const W = { triangle: 0, saw: 1, square: 2, organ: 3 } as const; + +/** MIDI note from a name like `"C4"`, `"F#3"`, `"Bb5"`. */ +export function n(name: string): number { + const m = /^([A-G])([#b]?)(-?\d+)$/.exec(name); + if (!m) throw new Error(`bad note name: ${name}`); + const base = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }[m[1]!]!; + const accidental = m[2] === "#" ? 1 : m[2] === "b" ? -1 : 0; + const octave = Number(m[3]); + return (octave + 1) * 12 + base + accidental; +} + +/** One cell. */ +export type Cell = [note: number, param: number, volume: number, flags: number]; +/** One row: four cells, one per channel. */ +export type Row = [Cell, Cell, Cell, Cell]; + +const rest: Cell = [HOLD, 0, 0, 0]; +const off: Cell = [OFF, 0, 0, 0]; + +/** A row from optional per-channel cells. */ +function row(lead?: Cell, harm?: Cell, bass?: Cell, drum?: Cell): Row { + return [lead ?? rest, harm ?? rest, bass ?? rest, drum ?? rest]; +} + +/** A lead note. */ +const L = (name: string, vol = 11, duty = D.square): Cell => [n(name), duty, vol, 1]; +/** A harmony note, quieter by default so the lead stays on top. */ +const H = (name: string, vol = 7, duty = D.quarter): Cell => [n(name), duty, vol, 1]; +/** A bass note on the wave channel. */ +const B = (name: string, vol = 12, wave = W.triangle): Cell => [n(name), wave, vol, 1]; +/** A percussion hit; `shift` picks the noise period (higher = duller). */ +const P = (vol = 8, shift = 3): Cell => [n("C5"), shift, vol, 1]; + +export interface Track { + name: string; + /** Tempo in ROWS per minute (not beats — a row is a sixteenth here). */ + rowsPerMinute: number; + /** Row to jump back to at the end; `undefined` plays once. */ + loopRow?: number; + rows: Row[]; +} + +// --------------------------------------------------------------------------- +// Songs +// --------------------------------------------------------------------------- + +/** + * SPARKWOOD VILLAGE — the town theme. A gentle two-bar loop in C, the lead + * walking up the scale while the bass alternates root and fifth. + */ +const village: Track = { + name: "village", + rowsPerMinute: 420, + loopRow: 0, + rows: [ + row(L("C5"), H("E4"), B("C3"), P(6, 4)), + row(), + row(L("E5"), undefined, undefined), + row(), + row(L("G5"), H("C4"), B("G2"), P(4, 5)), + row(), + row(L("E5"), undefined, undefined), + row(), + row(L("F5"), H("A4"), B("F2"), P(6, 4)), + row(), + row(L("A5"), undefined, undefined), + row(), + row(L("G5"), H("B3"), B("G2"), P(4, 5)), + row(), + row(L("E5"), undefined, undefined), + row(off), + row(L("D5"), H("F4"), B("D3"), P(6, 4)), + row(), + row(L("F5"), undefined, undefined), + row(), + row(L("A5"), H("D4"), B("A2"), P(4, 5)), + row(), + row(L("G5"), undefined, undefined), + row(), + row(L("E5"), H("G3"), B("C3"), P(6, 4)), + row(), + row(L("C5"), undefined, undefined), + row(), + row(L("G4"), H("E3"), B("G2"), P(4, 5)), + row(), + row(), + row(off, off, off), + ], +}; + +/** + * ROUTE ONE — walking music. Faster, in A minor, with a steadier kick so it + * reads as travel rather than home. + */ +const route: Track = { + name: "route", + rowsPerMinute: 520, + loopRow: 0, + rows: [ + row(L("A4"), H("C4"), B("A2"), P(9, 2)), + row(), + row(L("C5"), undefined, undefined, P(5, 4)), + row(), + row(L("E5"), H("A3"), B("E2"), P(9, 2)), + row(), + row(L("D5"), undefined, undefined, P(5, 4)), + row(), + row(L("C5"), H("E4"), B("F2"), P(9, 2)), + row(), + row(L("B4"), undefined, undefined, P(5, 4)), + row(), + row(L("A4"), H("C4"), B("G2"), P(9, 2)), + row(), + row(), + row(off, off, off, P(5, 4)), + row(L("E5"), H("G4"), B("A2"), P(9, 2)), + row(), + row(L("D5"), undefined, undefined, P(5, 4)), + row(), + row(L("C5"), H("E4"), B("E2"), P(9, 2)), + row(), + row(L("B4"), undefined, undefined, P(5, 4)), + row(), + row(L("A4"), H("C4"), B("F2"), P(9, 2)), + row(), + row(L("G4"), undefined, B("G2"), P(5, 4)), + row(), + row(L("A4"), H("A3"), B("A2"), P(9, 2)), + row(), + row(), + row(off, off, off), + ], +}; + +/** + * ENCOUNTER — the wild battle theme. Urgent, chromatic, and short enough that + * a two-turn fight hears the whole thing. + */ +const battle: Track = { + name: "battle", + rowsPerMinute: 640, + loopRow: 4, + rows: [ + // A four-row sting before the loop proper. + row(L("A5", 13, D.thin), H("A4", 9), B("A2", 13), P(12, 1)), + row(L("G#5", 13, D.thin), undefined, undefined, P(8, 2)), + row(L("A5", 13, D.thin), undefined, undefined, P(12, 1)), + row(off, off, off, P(8, 2)), + + row(L("A4"), H("E4"), B("A2"), P(11, 2)), + row(L("A4"), undefined, undefined, P(6, 4)), + row(L("C5"), undefined, undefined, P(9, 3)), + row(L("A4"), undefined, undefined, P(6, 4)), + row(L("D5"), H("F4"), B("D3"), P(11, 2)), + row(L("C5"), undefined, undefined, P(6, 4)), + row(L("A4"), undefined, undefined, P(9, 3)), + row(L("G4"), undefined, undefined, P(6, 4)), + row(L("F4"), H("A3"), B("F2"), P(11, 2)), + row(L("G4"), undefined, undefined, P(6, 4)), + row(L("A4"), undefined, undefined, P(9, 3)), + row(L("C5"), undefined, undefined, P(6, 4)), + row(L("E5"), H("G4"), B("E2"), P(11, 2)), + row(L("D5"), undefined, undefined, P(6, 4)), + row(L("C5"), undefined, undefined, P(9, 3)), + row(L("B4"), undefined, undefined, P(6, 4)), + ], +}; + +export const SONGS: Track[] = [village, route, battle]; + +// --------------------------------------------------------------------------- +// Effects +// --------------------------------------------------------------------------- +// +// Short, one-shot, and loud enough to sit over the music. + +const bump: Track = { + name: "bump", + rowsPerMinute: 1800, + rows: [row(undefined, undefined, undefined, P(13, 5)), row(off, off, off, off)], +}; + +const select: Track = { + name: "select", + rowsPerMinute: 2400, + rows: [row(L("E6", 12, D.quarter)), row(L("B6", 10, D.quarter)), row(off, off, off, off)], +}; + +const hit: Track = { + name: "hit", + rowsPerMinute: 1600, + rows: [ + row(undefined, undefined, undefined, P(14, 1)), + row(undefined, undefined, undefined, P(9, 3)), + row(off, off, off, off), + ], +}; + +const faint: Track = { + name: "faint", + rowsPerMinute: 900, + rows: [ + row(L("A4", 12, D.square)), + row(L("F4", 11, D.square)), + row(L("D4", 10, D.square)), + row(L("A3", 9, D.square)), + row(off, off, off, off), + ], +}; + +const heal: Track = { + name: "heal", + rowsPerMinute: 1100, + rows: [ + row(L("C5", 10, D.quarter)), + row(L("E5", 10, D.quarter)), + row(L("G5", 10, D.quarter)), + row(L("C6", 11, D.quarter)), + row(off, off, off, off), + ], +}; + +export const SFX: Track[] = [bump, select, hit, faint, heal]; + +/** Effect ids, for content that fires them by name. */ +export const SFX_ID = { bump: 0, select: 1, hit: 2, faint: 3, heal: 4 } as const; +/** Song ids; a map's `music` field is one of these plus one (0 = silence). */ +export const SONG_ID = { village: 0, route: 1, battle: 2 } as const; + +/** Encode one track into the AUDO track layout. */ +export function encodeTrack(t: Track): Uint8Array { + const cells = t.rows.length * 4; + const out = new Uint8Array(8 + cells * 4); + const dv = new DataView(out.buffer); + dv.setUint16(0, t.rowsPerMinute, true); + dv.setUint16(2, t.rows.length, true); + out[4] = 4; // channels + out[5] = t.loopRow ?? 0xff; + dv.setUint16(6, 0, true); + let at = 8; + for (const r of t.rows) { + for (const cell of r) { + out[at] = cell[0] & 0xff; + out[at + 1] = cell[1] & 0xff; + out[at + 2] = cell[2] & 0xff; + out[at + 3] = cell[3] & 0xff; + at += 4; + } + } + return out; +} diff --git a/apps/mon/cook.ts b/apps/mon/cook.ts index 2df7b8ad..b3ae62bd 100644 --- a/apps/mon/cook.ts +++ b/apps/mon/cook.ts @@ -22,6 +22,7 @@ import { SLOT_COUNT, } from "../../contracts/spec/mon-spec.ts"; import { CAST, drawCast, SPRITE_PX } from "./art/actors.ts"; +import { encodeTrack, SFX, SONGS } from "./content/music.ts"; import { drawCreature, PORTRAIT_PX } from "./art/creatures.ts"; import { advanceOf, CELL as FONT_CELL, characters, rasterize } from "./art/font.ts"; import { PAL, packPalette } from "./art/palette.ts"; @@ -508,6 +509,27 @@ function patchU16(w: Writer, at: number, v: number): void { buf[at + 1] = (v >> 8) & 0xff; } +/** + * AUDO: `songCount + sfxCount + 1` offsets (the extra one bounds the last + * track), then the tracks back to back. + */ +function sectionAudio(): Uint8Array { + const tracks = [...SONGS, ...SFX].map(encodeTrack); + const w = new Writer(1 << 14); + w.u16(SONGS.length).u16(SFX.length); + const tableAt = w.length; + for (let i = 0; i <= tracks.length; i++) w.u32(0); + const starts: number[] = []; + for (const bytes of tracks) { + starts.push(w.length); + w.bytes(bytes); + } + const end = w.length; + starts.forEach((at, i) => w.patchU32(tableAt + i * 4, at)); + w.patchU32(tableAt + tracks.length * 4, end); + return w.finish(); +} + function sectionText(text: TextTable): Uint8Array { const strings = text.all(); const encoder = new TextEncoder(); @@ -553,6 +575,7 @@ export function cook(): Uint8Array { { tag: MONPAK_TAG.scripts, count: SCRIPTS.length, payload: sectionScripts(text, scriptKeys) }, { tag: MONPAK_TAG.maps, count: MAPS.length, payload: sectionMaps(text, scriptKeys) }, { tag: MONPAK_TAG.font, count: characters().length, payload: sectionFont() }, + { tag: MONPAK_TAG.audio, count: SONGS.length + SFX.length, payload: sectionAudio() }, ]; // TEXT goes last so every key interned above is included. sections.push({ tag: MONPAK_TAG.text, count: text.size, payload: sectionText(text) }); @@ -597,6 +620,7 @@ export function summary(pak: Uint8Array): string { ` ${SPECIES.length} species, ${MOVES.length} moves, ${TYPE_NAMES.length} types`, ` ${MAPS.length} maps, ${TRAINERS.length} trainers, ${SCRIPTS.length} scripts`, ` ${CAST.length} actor sheets (${SPRITE_PX}px), ${SPECIES.length * 2} portraits (${PORTRAIT_PX}px)`, + ` ${SONGS.length} songs, ${SFX.length} sound effects`, ].join("\n"); } diff --git a/contracts/spec/gen-mon-rust.ts b/contracts/spec/gen-mon-rust.ts index 9a685850..4abd0695 100644 --- a/contracts/spec/gen-mon-rust.ts +++ b/contracts/spec/gen-mon-rust.ts @@ -13,7 +13,11 @@ import { ACTORS_MAX, ACTOR_SIZE, ATLAS_PAGE_HEADER_SIZE, + AUDIO_BUFFER, + AUDIO_CELL_SIZE, + AUDIO_CHANNELS, AUDIO_ENTRY_SIZE, + AUDIO_HEADER_SIZE, BAG_MAX, BALL_RATE, BEHAVIOR, @@ -66,6 +70,8 @@ import { MOVE_FLAG_PRIORITY, MOVE_FLAG_RECHARGE, MOVE_SIZE, + NOTE_HOLD, + NOTE_OFF, OUTCOME, PAGE_MAX, PAGE_PX, @@ -81,6 +87,7 @@ import { RECT_SIZE, SAVE_HEADER_SIZE, SAVE_MAGIC, + SAMPLE_RATE, SAVE_VERSION, SCRIPT_ENTRY_SIZE, SCRIPT_HEADER_SIZE, @@ -294,6 +301,16 @@ export function generateMonRust(): string { put("/// A string's index IS its key id; no hashing happens at runtime."); put(`pub const TEXT_ENTRY_SIZE: usize = ${TEXT_ENTRY_SIZE};`); put(`pub const AUDIO_ENTRY_SIZE: usize = ${AUDIO_ENTRY_SIZE};`); + put("/// A track is a tracker pattern; see mon-spec.ts for the cell layout."); + put(`pub const AUDIO_HEADER_SIZE: usize = ${AUDIO_HEADER_SIZE};`); + put(`pub const AUDIO_CELL_SIZE: usize = ${AUDIO_CELL_SIZE};`); + put("/// Pulse 1, pulse 2, wave, noise — the classic four."); + put(`pub const AUDIO_CHANNELS: usize = ${AUDIO_CHANNELS};`); + put(`pub const SAMPLE_RATE: u32 = ${SAMPLE_RATE};`); + put(`pub const AUDIO_BUFFER: usize = ${AUDIO_BUFFER};`); + put("/// Cell `note` values that are not a semitone."); + put(`pub const NOTE_HOLD: u8 = ${NOTE_HOLD};`); + put(`pub const NOTE_OFF: u8 = ${NOTE_OFF};`); put(""); constMod(put, "item_kind", "u8", ITEM_KIND, ["What an item does when used."]); diff --git a/contracts/spec/mon-spec.ts b/contracts/spec/mon-spec.ts index e26389e0..a8aedf3a 100644 --- a/contracts/spec/mon-spec.ts +++ b/contracts/spec/mon-spec.ts @@ -496,11 +496,37 @@ export const SCRIPT_ENTRY_SIZE = 12; export const TEXT_ENTRY_SIZE = 8; /** - * AUDO header: u16 songCount, u16 sfxCount; then a u32 offset table of - * songCount + sfxCount entries, then the channel programs (see audio.rs for - * the note-event stream layout). + * AUDO header: u16 songCount, u16 sfxCount; then a u32 offset table with + * `songCount + sfxCount + 1` entries (the extra one bounds the last track), + * then the tracks. + * + * A track is a tracker pattern — the compact shape a four-channel chip wants, + * and the one a human can actually author by hand: + * + * 0 u16 rowsPerMinute tempo, in rows (not beats) per minute + * 2 u16 rows pattern length + * 4 u8 channels always AUDIO_CHANNELS + * 5 u8 loopRow row to jump back to at the end; 0xff = play once + * 6 u16 reserved + * 8 rows * channels cells of AUDIO_CELL_SIZE bytes: + * u8 note 0 = hold, 1 = note off, else a semitone index where + * 69 is A4 (the MIDI convention, so a tuner agrees) + * u8 param pulse: duty 0..3 | wave: table 0..3 | noise: period + * u8 volume 0..15, the chip's four-bit range + * u8 flags bit 0 = restart the envelope even on a held note */ export const AUDIO_ENTRY_SIZE = 4; +export const AUDIO_HEADER_SIZE = 8; +export const AUDIO_CELL_SIZE = 4; +/** Pulse 1, pulse 2, wave, noise — the classic four. */ +export const AUDIO_CHANNELS = 4; +/** Output rate. The PSP's audio hardware wants 44.1 kHz. */ +export const SAMPLE_RATE = 44100; +/** Samples per host buffer; the PSP's channel granularity is 64. */ +export const AUDIO_BUFFER = 1024; +/** Cell `note` values with a meaning other than "play this semitone". */ +export const NOTE_HOLD = 0; +export const NOTE_OFF = 1; // --------------------------------------------------------------------------- // Record layouts (fixed-size, LE) — the cooker writes these, the core reads them diff --git a/docs/MON.md b/docs/MON.md index 04652670..dedff9f5 100644 --- a/docs/MON.md +++ b/docs/MON.md @@ -70,20 +70,32 @@ is specified in the contract and *implemented* as `Game::op` — one dispatcher covering every op, exercised by `cargo test` — and `apps/mon/sdk.ts` is the guest-side algebra over it. -**What is not wired yet.** No QuickJS realm is mounted in the EBOOT, so -`Game::op` currently has no JS caller on the console: the shipped game's -behaviour comes from its cooked content plus the native script VM. Mounting -`pocket-mod` (or the raw QuickJS embedding `pocketjs-psp` already carries) and -binding the dispatcher is a thin layer over a surface that is already written -and tested — but it is not done, and this document will not pretend otherwise. -The same goes for mounting the `ui` surface for menus: the core draws its own -battle UI and dialogue today. - -The base game is meant to be the first mod (RUNTIMES.md discipline #5), and -the content path already honours it — every species, move, map, script and -trainer is data, and the core ships none of it. What is missing is only the -realm that would let that data arrive from a running JS program rather than a -cooked file. +**Where the guest is.** TypeScript is the authoring language here, and it is +**ahead-of-time compiled** rather than interpreted on the console: `cook.ts` +turns the content and the scripts into a MONPAK, and the core's script VM runs +the compiled verbs. That is the [`vapor/`](../vapor) precedent rather than the +`hosts/psp` one, and it is a deliberate choice — it keeps the entire runtime to +two languages, Rust and TypeScript, with no C anywhere in the tree. The 2D UI +runtime embeds QuickJS (a C library) to interpret its guest; this one does not +need to, because a creature RPG's guest work is content and rules, and both +compile. + +The consequence is worth stating plainly: `Game::op` has no JS caller on the +console today. The surface is specified, implemented as one dispatcher, and +covered by `cargo test`; `apps/mon/sdk.ts` is the guest-side algebra over it. +What is missing is a realm, and adding one is a thin binding over a boundary +that already exists — `pocketjs-psp` carries the QuickJS embedding, and +`pocket-mod` carries the realm lifecycle. It is a decision left open, not an +oversight: mount it when live modding is worth a C dependency. + +The base game is meant to be the first mod (RUNTIMES.md discipline #5), and the +content path already honours the half that matters — every species, move, map, +script and trainer is data, and the core ships none of it. A second pak merged +over the first is a mod today; a running JS program would be one tomorrow. + +The `ui` surface is likewise not mounted: the core draws its own battle UI and +dialogue from its own state, which is what keeps an overworld frame at zero +boundary crossings. ### Where the upstream Lua modules land @@ -108,7 +120,7 @@ cooked file. | `data/scripts/*.lua` | **guest** | `apps/mon/content/game.ts` `SCRIPTS`, compiled by the cooker | | `mods/*` | **guest** | the surface *is* the mod API (`apps/mon/sdk.ts`) | | `import/*` | — | *(deliberately absent — see §1)* | -| `core/ChipSynth`, `ChipAudio`, `Music` | — | **not implemented.** The core states what should be sounding (`Game::music`, `sfx`, `cry`) and no host acts on it yet | +| `core/ChipSynth`, `ChipAudio`, `Music` | **core** | `audio.rs` — four voices (2 pulse, wave, noise), integer-only, rendered on demand | | `link/*` (UDP link play) | — | out of scope for v1 | ### Why the split falls where it does @@ -228,7 +240,32 @@ A runtime without a headless story is not done (discipline #4): the sim goldens are describing a picture the console never shows. - Real hardware, over PSPLINK — **not yet run**. -## 7. Out of scope for v1 +## 7. Sound + +Four voices, the classic set: two pulse channels with selectable duty and a +volume envelope, a wave channel over a 32-step 4-bit table, and a noise channel +driven by a 15-bit LFSR. Integer only, like the rest of the core — phase is a +16.16 accumulator and the note table is frequencies in millihertz, because a +synth that rounds differently on two hosts is a bug that gets blamed on +something else. + +Tracks are tracker patterns (`apps/mon/content/music.ts`): four channels, one +cell per row, tempo in rows per minute. That is the shape a four-voice chip +wants and the shape a person can read back, which matters when the score is +hand-authored rather than extracted. + +The core never touches a device — it renders samples into a buffer the host +asks for. On the PSP that host is a dedicated thread: `sceAudioOutputBlocking` +at 1024 samples paces at ~43 Hz and would cap the frame loop below 60 if it ran +inline. The frame loop posts requests through three atomics and a sequence +counter, which is the smallest shared surface that works and needs no lock. + +`bun tools/mon.ts sim --audio

` renders every song and effect to +WAV. Unit tests can prove the synth makes *a* sound; only a listen tells you +the tune is right — and that gap is exactly how the mix shipped at 0.3% of full +scale for an afternoon. + +## 8. Out of scope for v1 Link play (upstream's UDP `src/link/*`), the save editor, Discord presence, the mod manager UI, and the slot-machine minigame. None of them are load diff --git a/engine/pocketmon/crates/pocketmon-core/src/audio.rs b/engine/pocketmon/crates/pocketmon-core/src/audio.rs new file mode 100644 index 00000000..fe76577f --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/audio.rs @@ -0,0 +1,640 @@ +//! The chip synthesizer. +//! +//! Ported from upstream `src/core/ChipSynth.lua` + `ChipAudio.lua`, minus the +//! half of those files that exists to decode channel programs out of a ROM. +//! Four voices, the classic set: +//! +//! | channel | voice | +//! | --- | --- | +//! | 0, 1 | pulse, four duty cycles, with a volume envelope | +//! | 2 | wave, a 32-step 4-bit table | +//! | 3 | noise, a 15-bit LFSR | +//! +//! ## Integer only, like everything else +//! +//! Phase is a 16.16 fixed-point accumulator and the note table is precomputed +//! frequencies in millihertz. No floats: the PSP's FPU and a desktop's do not +//! round identically, and while audio does not feed the frame goldens, a synth +//! that drifts between hosts is a bug waiting to be blamed on something else. +//! +//! ## What the core does and does not do +//! +//! It renders samples on demand into a caller-provided buffer. It does not own +//! a device, a thread or a clock — the host pulls. That keeps the same rule the +//! rest of the core follows: state and time live here, but I/O does not. + +use alloc::vec::Vec; + +use crate::content::Content; +use crate::spec; + +/// Just the audio the synth needs, detached from the content registry. +/// +/// The PSP renders audio on its own thread — `sceAudioOutputBlocking` at 1024 +/// samples paces at ~43 Hz and would cap the frame loop if it ran inline — and +/// that thread has no business holding the map and species tables. Cloning a +/// few kilobytes of track data at boot buys a clean split. +#[derive(Clone, Debug, Default)] +pub struct Bank { + pub tracks: Vec>, + pub songs: u16, +} + +impl Bank { + pub fn from_content(content: &Content) -> Bank { + Bank { tracks: content.audio.clone(), songs: content.song_count } + } + + fn track(&self, index: usize) -> Option<&[u8]> { + self.tracks.get(index).map(Vec::as_slice) + } +} + +/// Semitone frequencies in millihertz for MIDI notes 0..=127. +/// +/// Computed rather than tabled would need a float `powf`; tabling the twelve +/// ratios and shifting by octave keeps it exact and small. +const RATIO_MHZ: [u32; 12] = [ + // C..B at octave -1 (MIDI 0..11), millihertz. + 8176, 8662, 9177, 9723, 10301, 10913, 11562, 12250, 12978, 13750, 14568, 15434, +]; + +/// Frequency of a MIDI note, in millihertz. +pub fn note_mhz(note: u8) -> u32 { + let octave = (note / 12) as u32; + RATIO_MHZ[(note % 12) as usize] << octave.min(12) +} + +/// Duty patterns as 8-step masks: 12.5%, 25%, 50%, 75%. +const DUTY: [u8; 4] = [0b0000_0001, 0b0000_0011, 0b0000_1111, 0b0011_1111]; + +/// Four wave tables, 32 steps of 4 bits each: triangle, saw, square, and a +/// hollow "organ" shape. +const WAVES: [[u8; 32]; 4] = [ + [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, + 5, 4, 3, 2, 1, 0, + ], + [ + 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13, + 14, 14, 15, 15, + ], + [ + 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 15, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, + ], + [ + 8, 12, 15, 15, 12, 8, 4, 1, 0, 1, 4, 8, 12, 15, 15, 12, 8, 4, 1, 0, 1, 4, 8, 12, 14, 12, 8, + 4, 2, 4, 8, 12, + ], +]; + +/// One voice. +#[derive(Clone, Copy, Debug, Default)] +struct Voice { + /// 16.16 phase accumulator. + phase: u32, + /// 16.16 phase increment per sample. + step: u32, + /// 0..15. + volume: u8, + /// Envelope: volume decrements every `decay` samples; 0 = sustain. + decay: u32, + decay_at: u32, + /// Duty index, wave table index, or noise period shift. + param: u8, + on: bool, + /// Noise LFSR state. + lfsr: u16, +} + +impl Voice { + fn note_on(&mut self, note: u8, param: u8, volume: u8, restart: bool) { + self.step = step_for(note); + self.param = param; + self.volume = volume.min(15); + self.on = true; + if restart { + self.phase = 0; + self.decay_at = 0; + } + if self.lfsr == 0 { + self.lfsr = 0x7fff; + } + } + + fn note_off(&mut self) { + self.on = false; + self.volume = 0; + } + + /// One sample in -15..=15 before mixing. + fn sample(&mut self, kind: usize) -> i32 { + if !self.on || self.volume == 0 { + return 0; + } + // Envelope. + if self.decay > 0 { + self.decay_at += 1; + if self.decay_at >= self.decay { + self.decay_at = 0; + self.volume = self.volume.saturating_sub(1); + if self.volume == 0 { + self.on = false; + return 0; + } + } + } + + let amp = self.volume as i32; + match kind { + 0 | 1 => { + // Pulse: eight phase slots against a duty mask. + self.phase = self.phase.wrapping_add(self.step); + let slot = (self.phase >> 13) & 7; + let mask = DUTY[(self.param & 3) as usize]; + if mask & (1 << slot) != 0 { + amp + } else { + -amp + } + } + 2 => { + // Wave: 32 steps of a 4-bit table, centred. + self.phase = self.phase.wrapping_add(self.step); + let slot = ((self.phase >> 11) & 31) as usize; + let v = WAVES[(self.param & 3) as usize][slot] as i32; + (v - 8) * amp / 8 + } + _ => { + // Noise: clock a 15-bit LFSR at a rate set by `param`. + self.phase = self.phase.wrapping_add(self.step >> (self.param & 7).min(7)); + if self.phase & 0x8000 != 0 { + self.phase &= 0x7fff; + let bit = (self.lfsr ^ (self.lfsr >> 1)) & 1; + self.lfsr = (self.lfsr >> 1) | (bit << 14); + } + if self.lfsr & 1 != 0 { + amp + } else { + -amp + } + } + } + } +} + +/// The 16.16 phase increment for one semitone at the output rate. +fn step_for(note: u8) -> u32 { + // phase wraps at 1<<16 per cycle; step = freq * 65536 / rate. + let mhz = note_mhz(note) as u64; + ((mhz * 65536) / (spec::SAMPLE_RATE as u64 * 1000)) as u32 +} + +/// A playing track: which one, and where in it. +#[derive(Clone, Copy, Debug, Default)] +struct Cursor { + track: usize, + row: u16, + /// Samples until the next row. + countdown: u32, + samples_per_row: u32, + playing: bool, +} + +/// The synth: one music cursor, one effect cursor, four voices each. +/// +/// Two cursors rather than one mixer with priorities, because a sound effect +/// interrupting the music and then handing it back is exactly what the source +/// engine does and what players expect. +#[derive(Clone, Debug, Default)] +pub struct Synth { + music: Cursor, + sfx: Cursor, + music_voices: [Voice; spec::AUDIO_CHANNELS], + sfx_voices: [Voice; spec::AUDIO_CHANNELS], + /// 0..=255, applied to everything. + pub volume: u8, +} + +/// A parsed track header. +struct Track<'a> { + rows: u16, + loop_row: u8, + samples_per_row: u32, + cells: &'a [u8], +} + +fn track<'a>(bank: &'a Bank, index: usize) -> Option> { + let bytes = bank.track(index)?; + if bytes.len() < spec::AUDIO_HEADER_SIZE { + return None; + } + let rpm = u16::from_le_bytes([bytes[0], bytes[1]]).max(1) as u32; + let rows = u16::from_le_bytes([bytes[2], bytes[3]]); + let channels = bytes[4] as usize; + let loop_row = bytes[5]; + if channels != spec::AUDIO_CHANNELS || rows == 0 { + return None; + } + let need = rows as usize * channels * spec::AUDIO_CELL_SIZE; + let cells = bytes.get(spec::AUDIO_HEADER_SIZE..spec::AUDIO_HEADER_SIZE + need)?; + Some(Track { + rows, + loop_row, + samples_per_row: spec::SAMPLE_RATE * 60 / rpm, + cells, + }) +} + +impl Synth { + pub fn new() -> Self { + Synth { volume: 200, ..Default::default() } + } + + pub fn music_playing(&self) -> bool { + self.music.playing + } + + pub fn sfx_playing(&self) -> bool { + self.sfx.playing + } + + /// Start a song (an index into the AUDO songs). + pub fn play_music(&mut self, bank: &Bank, id: u16) { + let index = id as usize; + if index >= bank.songs as usize || track(bank, index).is_none() { + self.stop_music(); + return; + } + // Restarting the song already playing would stutter it every time a + // map with the same music is entered. + if self.music.playing && self.music.track == index { + return; + } + let t = track(bank, index).expect("checked"); + self.music = Cursor { + track: index, + row: 0, + countdown: 0, + samples_per_row: t.samples_per_row, + playing: true, + }; + self.music_voices = Default::default(); + } + + pub fn stop_music(&mut self) { + self.music.playing = false; + self.music_voices = Default::default(); + } + + /// Start a one-shot effect (an index past the songs). + pub fn play_sfx(&mut self, bank: &Bank, id: u16) { + let index = bank.songs as usize + id as usize; + let Some(t) = track(bank, index) else { + return; + }; + self.sfx = Cursor { + track: index, + row: 0, + countdown: 0, + samples_per_row: t.samples_per_row, + playing: true, + }; + self.sfx_voices = Default::default(); + } + + /// Advance one cursor by one row, applying its cells. + fn step_row(cursor: &mut Cursor, voices: &mut [Voice; spec::AUDIO_CHANNELS], bank: &Bank) { + let Some(t) = track(bank, cursor.track) else { + cursor.playing = false; + return; + }; + if cursor.row >= t.rows { + if t.loop_row == 0xff || t.loop_row as u16 >= t.rows { + cursor.playing = false; + for v in voices.iter_mut() { + v.note_off(); + } + return; + } + cursor.row = t.loop_row as u16; + } + let base = cursor.row as usize * spec::AUDIO_CHANNELS * spec::AUDIO_CELL_SIZE; + for ch in 0..spec::AUDIO_CHANNELS { + let at = base + ch * spec::AUDIO_CELL_SIZE; + let Some(cell) = t.cells.get(at..at + spec::AUDIO_CELL_SIZE) else { + continue; + }; + let (note, param, volume, flags) = (cell[0], cell[1], cell[2], cell[3]); + match note { + spec::NOTE_HOLD => {} + spec::NOTE_OFF => voices[ch].note_off(), + n => voices[ch].note_on(n, param, volume, flags & 1 != 0 || !voices[ch].on), + } + } + cursor.row += 1; + cursor.countdown = cursor.samples_per_row; + } + + /// Render interleaved stereo samples. The host calls this to fill a buffer. + /// + /// Both cursors mix; a sound effect does not stop the music, it sits on top + /// of it, which is what the source engine's separate SFX channels do. + pub fn render(&mut self, bank: &Bank, out: &mut [i16]) { + let gain = self.volume as i32; + for frame in out.chunks_exact_mut(2) { + if self.music.playing && self.music.countdown == 0 { + let mut cursor = self.music; + Self::step_row(&mut cursor, &mut self.music_voices, bank); + self.music = cursor; + } + if self.sfx.playing && self.sfx.countdown == 0 { + let mut cursor = self.sfx; + Self::step_row(&mut cursor, &mut self.sfx_voices, bank); + self.sfx = cursor; + } + + let mut mix = 0i32; + for (i, v) in self.music_voices.iter_mut().enumerate() { + mix += v.sample(i); + } + for (i, v) in self.sfx_voices.iter_mut().enumerate() { + // Effects sit slightly louder than the bed so they cut through. + mix += v.sample(i) * 3 / 2; + } + // Gain staging. Four voices at +-15 sum to +-60, and an effect + // adds half again, so the worst case is about +-90. 320 per step + // puts that near full scale with a little room, and the master + // volume rides on top. (An earlier version scaled as though the + // output were 8-bit and peaked at 0.3% — inaudible, and the unit + // tests happily passed because they only asserted "not silent".) + let s = (mix * 320 * gain / 255).clamp(-32767, 32767) as i16; + frame[0] = s; + frame[1] = s; + + if self.music.playing { + self.music.countdown = self.music.countdown.saturating_sub(1); + } + if self.sfx.playing { + self.sfx.countdown = self.sfx.countdown.saturating_sub(1); + } + } + } + + /// Render into a fresh buffer — the shape tests and offline renders want. + pub fn render_vec(&mut self, bank: &Bank, frames: usize) -> Vec { + let mut out = alloc::vec![0i16; frames * 2]; + self.render(bank, &mut out); + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::vec; + + /// Build a one-track AUDO payload: `rows` rows, all four channels. + fn make_track(rpm: u16, rows: &[[[u8; 4]; spec::AUDIO_CHANNELS]], loop_row: u8) -> Vec { + let mut out = Vec::new(); + out.extend_from_slice(&rpm.to_le_bytes()); + out.extend_from_slice(&(rows.len() as u16).to_le_bytes()); + out.push(spec::AUDIO_CHANNELS as u8); + out.push(loop_row); + out.extend_from_slice(&0u16.to_le_bytes()); + for row in rows { + for cell in row { + out.extend_from_slice(cell); + } + } + out + } + + fn content_with(tracks: Vec>, songs: u16) -> Bank { + Bank { tracks, songs } + } + + fn silence(n: usize) -> Vec<[[u8; 4]; spec::AUDIO_CHANNELS]> { + vec![[[0, 0, 0, 0]; spec::AUDIO_CHANNELS]; n] + } + + #[test] + fn note_frequencies_land_on_the_tuning_reference() { + // MIDI 69 is A4 = 440 Hz; 57 is A3, 81 is A5. + assert_eq!(note_mhz(69), 440_000); + assert_eq!(note_mhz(57), 220_000); + assert_eq!(note_mhz(81), 880_000); + assert_eq!(note_mhz(60), 261_632); // middle C, within a millihertz + } + + #[test] + fn octaves_double_exactly() { + for n in 0..108u8 { + assert_eq!(note_mhz(n + 12), note_mhz(n) * 2, "note {n}"); + } + } + + #[test] + fn silence_renders_silence() { + let c = content_with(vec![make_track(600, &silence(4), 0xff)], 1); + let mut s = Synth::new(); + s.play_music(&c, 0); + let out = s.render_vec(&c, 512); + assert!(out.iter().all(|&v| v == 0)); + } + + #[test] + fn a_note_actually_makes_sound() { + let mut rows = silence(4); + rows[0][0] = [69, 2, 15, 1]; // A4, 50% duty, full volume + let c = content_with(vec![make_track(600, &rows, 0xff)], 1); + let mut s = Synth::new(); + s.play_music(&c, 0); + let out = s.render_vec(&c, 2048); + assert!(out.iter().any(|&v| v != 0), "no output"); + // A square wave should swing both ways and roughly average to zero. + assert!(out.iter().any(|&v| v > 0)); + assert!(out.iter().any(|&v| v < 0)); + let mean: i64 = out.iter().map(|&v| v as i64).sum::() / out.len() as i64; + assert!(mean.abs() < 2000, "DC offset {mean}"); + } + + #[test] + fn a_higher_note_crosses_zero_more_often() { + let render = |note: u8| { + let mut rows = silence(2); + rows[0][0] = [note, 2, 15, 1]; + let c = content_with(vec![make_track(60, &rows, 0xff)], 1); + let mut s = Synth::new(); + s.play_music(&c, 0); + let out = s.render_vec(&c, 4410); // 100 ms + let mut crossings = 0; + for w in out.chunks_exact(2).collect::>().windows(2) { + if (w[0][0] >= 0) != (w[1][0] >= 0) { + crossings += 1; + } + } + crossings + }; + let low = render(57); // A3, 220 Hz -> ~44 crossings in 100 ms + let high = render(69); // A4, 440 Hz -> ~88 + assert!(high > low * 3 / 2, "A4 {high} vs A3 {low}"); + assert!((30..60).contains(&low), "A3 crossings {low}"); + } + + #[test] + fn a_note_off_stops_the_voice() { + let mut rows = silence(4); + rows[0][0] = [69, 2, 15, 1]; + rows[1][0] = [spec::NOTE_OFF, 0, 0, 0]; + let c = content_with(vec![make_track(6000, &rows, 0xff)], 1); + let mut s = Synth::new(); + s.play_music(&c, 0); + // One row at 6000 rpm is 441 samples; render past the second row. + let out = s.render_vec(&c, 2000); + let tail = &out[out.len() - 400..]; + assert!(tail.iter().all(|&v| v == 0), "voice kept sounding"); + } + + #[test] + fn a_one_shot_track_stops_on_its_own() { + let mut rows = silence(2); + rows[0][0] = [69, 2, 15, 1]; + let c = content_with(vec![make_track(6000, &rows, 0xff)], 1); + let mut s = Synth::new(); + s.play_music(&c, 0); + assert!(s.music_playing()); + s.render_vec(&c, 4000); + assert!(!s.music_playing(), "a non-looping track should end"); + } + + #[test] + fn a_looping_track_keeps_going() { + let mut rows = silence(2); + rows[0][0] = [69, 2, 15, 1]; + let c = content_with(vec![make_track(6000, &rows, 0)], 1); + let mut s = Synth::new(); + s.play_music(&c, 0); + s.render_vec(&c, 20_000); + assert!(s.music_playing(), "a looping track should not end"); + } + + #[test] + fn replaying_the_current_song_does_not_restart_it() { + // Every map entry asks for its music; restarting would stutter on + // every door. + let mut rows = silence(8); + rows[0][0] = [69, 2, 15, 1]; + let c = content_with(vec![make_track(600, &rows, 0)], 1); + let mut s = Synth::new(); + s.play_music(&c, 0); + s.render_vec(&c, 4000); + let row_before = s.music.row; + s.play_music(&c, 0); + assert_eq!(s.music.row, row_before, "the song restarted"); + } + + #[test] + fn an_unknown_song_stops_rather_than_playing_garbage() { + let c = content_with(vec![make_track(600, &silence(2), 0xff)], 1); + let mut s = Synth::new(); + s.play_music(&c, 0); + s.play_music(&c, 99); + assert!(!s.music_playing()); + assert!(s.render_vec(&c, 256).iter().all(|&v| v == 0)); + } + + #[test] + fn a_malformed_track_is_refused() { + // Truncated header, and a header promising more cells than it carries. + let short = vec![0u8; 4]; + let mut lying = make_track(600, &silence(1), 0xff); + lying[2] = 200; // claim 200 rows + let c = content_with(vec![short, lying], 2); + let mut s = Synth::new(); + s.play_music(&c, 0); + assert!(!s.music_playing()); + s.play_music(&c, 1); + assert!(!s.music_playing()); + } + + #[test] + fn an_effect_mixes_over_the_music_without_stopping_it() { + let mut music = silence(8); + music[0][0] = [60, 2, 10, 1]; + let mut sfx = silence(2); + sfx[0][3] = [72, 1, 15, 1]; // noise channel + let c = content_with(vec![make_track(600, &music, 0), make_track(6000, &sfx, 0xff)], 1); + let mut s = Synth::new(); + s.play_music(&c, 0); + let quiet = s.render_vec(&c, 1024); + s.play_sfx(&c, 0); + assert!(s.sfx_playing() && s.music_playing()); + let loud = s.render_vec(&c, 1024); + let energy = |b: &[i16]| b.iter().map(|&v| (v as i64).abs()).sum::(); + assert!(energy(&loud) > energy(&quiet), "the effect added nothing"); + assert!(s.music_playing(), "the effect stopped the music"); + } + + #[test] + fn rendering_is_reproducible() { + let mut rows = silence(4); + rows[0][0] = [64, 1, 12, 1]; + rows[0][3] = [70, 2, 8, 1]; + let c = content_with(vec![make_track(400, &rows, 0)], 1); + let run = || { + let mut s = Synth::new(); + s.play_music(&c, 0); + s.render_vec(&c, 4096) + }; + assert_eq!(run(), run()); + } + + #[test] + fn output_never_clips_out_of_range() { + // All four voices at full volume at once: the mix has to stay inside + // i16 without wrapping into a loud crack. + let mut rows = silence(2); + for ch in 0..spec::AUDIO_CHANNELS { + rows[0][ch] = [60 + ch as u8 * 4, 3, 15, 1]; + } + let c = content_with(vec![make_track(60, &rows, 0)], 1); + let mut s = Synth::new(); + s.volume = 255; + s.play_music(&c, 0); + let out = s.render_vec(&c, 8192); + assert!(out.iter().all(|&v| v > i16::MIN)); + // And it has to actually be loud: the failure this guards against is + // a mix that stays technically in range by being nearly silent. + let peak = out.iter().map(|&v| (v as i32).abs()).max().unwrap_or(0); + assert!(peak > 16_000, "peak {peak} is too quiet to hear"); + } + + #[test] + fn a_single_voice_still_reaches_a_useful_level() { + let mut rows = silence(2); + rows[0][0] = [69, 2, 15, 1]; + let c = content_with(vec![make_track(60, &rows, 0)], 1); + let mut s = Synth::new(); + s.play_music(&c, 0); + let out = s.render_vec(&c, 4096); + let peak = out.iter().map(|&v| (v as i32).abs()).max().unwrap_or(0); + assert!(peak > 3_000, "one voice peaked at {peak}"); + } + + #[test] + fn the_master_volume_scales_the_mix() { + let mut rows = silence(2); + rows[0][0] = [69, 2, 15, 1]; + let c = content_with(vec![make_track(60, &rows, 0)], 1); + let energy = |vol: u8| { + let mut s = Synth::new(); + s.volume = vol; + s.play_music(&c, 0); + s.render_vec(&c, 2048).iter().map(|&v| (v as i64).abs()).sum::() + }; + assert!(energy(255) > energy(128)); + assert_eq!(energy(0), 0); + } +} diff --git a/engine/pocketmon/crates/pocketmon-core/src/lib.rs b/engine/pocketmon/crates/pocketmon-core/src/lib.rs index b4bd84ab..3de41885 100644 --- a/engine/pocketmon/crates/pocketmon-core/src/lib.rs +++ b/engine/pocketmon/crates/pocketmon-core/src/lib.rs @@ -24,6 +24,7 @@ extern crate alloc; use alloc::vec; use alloc::vec::Vec; +pub mod audio; pub mod battle; pub mod content; pub mod draw; @@ -38,6 +39,21 @@ pub mod surface; pub mod text; pub mod world; +/// Sound-effect ids the CORE fires directly. Content owns the rest; these +/// four are the ones the engine itself has an opinion about, so they are +/// pinned here rather than left to a content author to remember. +/// The song a battle switches to. Content owns the rest of the score; this +/// one the engine picks, because a battle starting is an engine fact. +pub const BATTLE_SONG: i32 = 2; + +pub mod sfx { + pub const BUMP: i32 = 0; + pub const SELECT: i32 = 1; + pub const HIT: i32 = 2; + pub const FAINT: i32 = 3; + pub const HEAL: i32 = 4; +} + pub use content::Content; pub use draw::MonDrawList; pub use event::{EventQueue, MonEvent}; @@ -149,6 +165,10 @@ pub struct Game { pub sfx: i32, /// One-shot creature cry for this frame. pub cry: i32, + /// Bumped whenever `music`/`sfx`/`cry` changes, so a host on another + /// thread can tell "nothing new" from "the same request again" without + /// needing a lock over the whole game. + pub audio_seq: u32, /// Previous frame's button mask, for edge detection. prev_buttons: u32, /// Scratch buffer reused by the packed `events()` / `view()` reads. @@ -180,6 +200,7 @@ impl Game { music: -1, sfx: -1, cry: -1, + audio_seq: 0, prev_buttons: 0, scratch: Vec::new(), } @@ -263,6 +284,7 @@ impl Game { } self.text.tick(pressed, &mut self.events); + self.emit_world_audio(); self.apply_pending_block(); self.dispatch_events(); self.sync_mode(); @@ -271,6 +293,36 @@ impl Game { self.stats.events_dropped = self.events.dropped; } + /// Sounds the world makes on its own — the ones a content author should + /// never have to remember to ask for. + fn emit_world_audio(&mut self) { + if self.world.bumped { + self.request_sfx(sfx::BUMP); + } + // The right music for where we are. A battle takes over the score and + // hands it back on the way out; otherwise it is the map's own theme. + // `play_music` ignores a repeat of what is already playing, so walking + // between two maps that share a theme does not restart it. + let want = if self.battle.is_some() { + BATTLE_SONG + } else { + self.content + .map_of(self.world.map_id) + .map(|m| m.music_id as i32 - 1) + .unwrap_or(-1) + }; + if want != self.music { + self.music = want; + self.audio_seq = self.audio_seq.wrapping_add(1); + } + } + + /// Ask the host for a one-shot effect. + pub fn request_sfx(&mut self, id: i32) { + self.sfx = id; + self.audio_seq = self.audio_seq.wrapping_add(1); + } + /// Apply a `replace_block` a script requested (the VM cannot reach /// `Content` mutably from inside a borrow of it). fn apply_pending_block(&mut self) { diff --git a/engine/pocketmon/crates/pocketmon-core/src/spec.rs b/engine/pocketmon/crates/pocketmon-core/src/spec.rs index ab0a6263..068f9d69 100644 --- a/engine/pocketmon/crates/pocketmon-core/src/spec.rs +++ b/engine/pocketmon/crates/pocketmon-core/src/spec.rs @@ -308,6 +308,16 @@ pub const SCRIPT_ENTRY_SIZE: usize = 12; /// A string's index IS its key id; no hashing happens at runtime. pub const TEXT_ENTRY_SIZE: usize = 8; pub const AUDIO_ENTRY_SIZE: usize = 4; +/// A track is a tracker pattern; see mon-spec.ts for the cell layout. +pub const AUDIO_HEADER_SIZE: usize = 8; +pub const AUDIO_CELL_SIZE: usize = 4; +/// Pulse 1, pulse 2, wave, noise — the classic four. +pub const AUDIO_CHANNELS: usize = 4; +pub const SAMPLE_RATE: u32 = 44100; +pub const AUDIO_BUFFER: usize = 1024; +/// Cell `note` values that are not a semitone. +pub const NOTE_HOLD: u8 = 0; +pub const NOTE_OFF: u8 = 1; /// What an item does when used. pub mod item_kind { diff --git a/engine/pocketmon/crates/pocketmon-psp/src/audio.rs b/engine/pocketmon/crates/pocketmon-psp/src/audio.rs new file mode 100644 index 00000000..ca2c191e --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-psp/src/audio.rs @@ -0,0 +1,100 @@ +//! PSP audio output. +//! +//! The synth is pull-based and the console's audio API is push-based and +//! blocking, so this owns a thread. `sceAudioOutputBlocking` with a +//! 1024-sample buffer paces at ~43 Hz; running it inline would cap the frame +//! loop below 60, which is why it cannot live in `run()`. +//! +//! The thread owns its own [`Bank`] (a clone of the track data, a few kB) and +//! its own [`Synth`]. The frame loop never touches either: it posts requests +//! through atomics, which is the smallest possible shared surface and needs no +//! lock. A missed request would cost one sound effect, and the sequence +//! counter means that cannot happen silently. + +use core::sync::atomic::{AtomicI32, AtomicU32, Ordering}; + +use pocketmon_core::audio::{Bank, Synth}; +use pocketmon_core::spec; +use psp::sys::{self, AudioFormat, ThreadAttributes}; + +/// The mailbox the frame loop writes and the audio thread reads. +static SEQ: AtomicU32 = AtomicU32::new(0); +static MUSIC: AtomicI32 = AtomicI32::new(-1); +static SFX: AtomicI32 = AtomicI32::new(-1); +static CRY: AtomicI32 = AtomicI32::new(-1); + +/// The bank, handed over once at boot. Read only by the audio thread after +/// `start`, which is the whole reason a raw static is safe here. +static mut BANK: Option = None; + +/// Post this frame's audio state. Cheap enough to call every frame. +pub fn post(seq: u32, music: i32, sfx: i32, cry: i32) { + MUSIC.store(music, Ordering::Relaxed); + SFX.store(sfx, Ordering::Relaxed); + CRY.store(cry, Ordering::Relaxed); + // Released last: the thread reads the sequence first and only then trusts + // the three values, so it can never act on a half-written request. + SEQ.store(seq, Ordering::Release); +} + +/// Hand over the bank and start the mixer thread. +pub unsafe fn start(bank: Bank) { + BANK = Some(bank); + let id = sys::sceKernelCreateThread( + b"sparkwood_audio\0".as_ptr(), + audio_thread, + // Above the main thread's 32 so a busy frame cannot starve the mixer + // into an underrun, which is audible as a click. + 16, + 16 * 1024, + ThreadAttributes::USER, + core::ptr::null_mut(), + ); + if id.0 >= 0 { + sys::sceKernelStartThread(id, 0, core::ptr::null_mut()); + } +} + +unsafe extern "C" fn audio_thread(_args: usize, _argp: *mut core::ffi::c_void) -> i32 { + let Some(bank) = (*core::ptr::addr_of!(BANK)).clone() else { + return 0; + }; + let channel = sys::sceAudioChReserve(-1, spec::AUDIO_BUFFER as i32, AudioFormat::Stereo); + if channel < 0 { + return 0; + } + + let mut synth = Synth::new(); + let mut buffer = [0i16; spec::AUDIO_BUFFER * 2]; + let mut seen = u32::MAX; + + loop { + let seq = SEQ.load(Ordering::Acquire); + if seq != seen { + seen = seq; + let music = MUSIC.load(Ordering::Relaxed); + if music < 0 { + synth.stop_music(); + } else { + synth.play_music(&bank, music as u16); + } + let sfx = SFX.load(Ordering::Relaxed); + if sfx >= 0 { + synth.play_sfx(&bank, sfx as u16); + } + // A cry is just an effect slot chosen by species, wrapped into the + // available set rather than needing one track per creature. + let cry = CRY.load(Ordering::Relaxed); + if cry >= 0 { + synth.play_sfx(&bank, (cry as u16) % 4 + 1); + } + } + + synth.render(&bank, &mut buffer); + sys::sceAudioOutputBlocking( + channel, + 0x8000, // full volume; the synth already applies its own master + buffer.as_mut_ptr() as *mut core::ffi::c_void, + ); + } +} diff --git a/engine/pocketmon/crates/pocketmon-psp/src/main.rs b/engine/pocketmon/crates/pocketmon-psp/src/main.rs index ee3e5387..932e3019 100644 --- a/engine/pocketmon/crates/pocketmon-psp/src/main.rs +++ b/engine/pocketmon/crates/pocketmon-psp/src/main.rs @@ -28,11 +28,13 @@ extern crate alloc; mod arena; +mod audio; #[cfg(feature = "capture")] mod capture; use core::ffi::c_void; +use pocketmon_core::audio::Bank; use pocketmon_core::{spec, Game}; use pocketmon_gu::Backend; @@ -152,6 +154,8 @@ unsafe fn run() -> ! { ALLOC.free_bytes() / 1024, ); + audio::start(Bank::from_content(&game.content)); + // Start where a new game starts: the first map the content declares. let start = game.content.maps.keys().next().copied().unwrap_or(1); game.enter_map(start, 3, 3, spec::dir::DOWN); @@ -171,6 +175,7 @@ unsafe fn run() -> ! { // Drain the event batch the way a guest would, so the queue never // sits at its cap silently dropping facts. let _ = game.encode_events(); + audio::post(game.audio_seq, game.music, game.sfx, game.cry); game.render(); sys::sceGuStart(GuContextType::Direct, list_ptr()); diff --git a/engine/pocketmon/crates/pocketmon-sim/src/main.rs b/engine/pocketmon/crates/pocketmon-sim/src/main.rs index a511f209..59288843 100644 --- a/engine/pocketmon/crates/pocketmon-sim/src/main.rs +++ b/engine/pocketmon/crates/pocketmon-sim/src/main.rs @@ -22,6 +22,7 @@ mod png; mod raster; +mod wav; use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -162,6 +163,7 @@ fn main() { let mut scale = 2u32; let mut assert_mode = false; let mut atlas_dir: Option = None; + let mut audio_dir: Option = None; let mut emit_psp: Option = None; let mut i = 0; @@ -191,6 +193,10 @@ fn main() { i += 1; scale = args.get(i).and_then(|v| v.parse().ok()).unwrap_or(2).max(1); } + "--audio" => { + i += 1; + audio_dir = args.get(i).map(PathBuf::from); + } "--atlas" => { i += 1; atlas_dir = args.get(i).map(PathBuf::from); @@ -253,6 +259,32 @@ fn main() { println!(" font page {} line height {}, {} glyphs", game.content.font_page, game.content.font_line_height, game.content.glyphs.len()); } + // Rendering the score to WAV is the only way to actually check it. Unit + // tests can prove a synth makes *a* sound; they cannot tell you the tune + // is wrong. + if let Some(dir) = &audio_dir { + let _ = std::fs::create_dir_all(dir); + let bank = pocketmon_core::audio::Bank::from_content(&game.content); + for i in 0..bank.tracks.len() { + let song = i < bank.songs as usize; + let mut synth = pocketmon_core::audio::Synth::new(); + if song { + synth.play_music(&bank, i as u16); + } else { + synth.play_sfx(&bank, (i - bank.songs as usize) as u16); + } + // Long enough for a loop of the longest song, short enough to skim. + let frames = if song { spec::SAMPLE_RATE * 8 } else { spec::SAMPLE_RATE }; + let samples = synth.render_vec(&bank, frames as usize); + let kind = if song { "song" } else { "sfx" }; + let index = if song { i } else { i - bank.songs as usize }; + let path = dir.join(format!("{kind}{index}.wav")); + let _ = std::fs::write(&path, wav::encode(spec::SAMPLE_RATE, &samples)); + let peak = samples.iter().map(|&v| (v as i32).abs()).max().unwrap_or(0); + println!(" {} peak {peak}", path.display()); + } + } + // Start where a new game starts. let start = game.content.maps.keys().next().copied().unwrap_or(1); game.enter_map(start, 3, 3, spec::dir::DOWN); diff --git a/engine/pocketmon/crates/pocketmon-sim/src/wav.rs b/engine/pocketmon/crates/pocketmon-sim/src/wav.rs new file mode 100644 index 00000000..0d4f6064 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-sim/src/wav.rs @@ -0,0 +1,55 @@ +//! A 16-bit stereo WAV writer, for listening to the score. +//! +//! Forty lines against an audio dependency: a unit test can prove the synth +//! makes *a* sound, but only a human with a WAV can tell you the tune is +//! wrong. + +/// Encode interleaved stereo i16 samples as a RIFF/WAVE file. +pub fn encode(rate: u32, samples: &[i16]) -> Vec { + let data_bytes = samples.len() * 2; + let mut out = Vec::with_capacity(44 + data_bytes); + let channels: u16 = 2; + let bits: u16 = 16; + let block_align = channels * bits / 8; + let byte_rate = rate * block_align as u32; + + out.extend_from_slice(b"RIFF"); + out.extend_from_slice(&((36 + data_bytes) as u32).to_le_bytes()); + out.extend_from_slice(b"WAVE"); + + out.extend_from_slice(b"fmt "); + out.extend_from_slice(&16u32.to_le_bytes()); // PCM chunk size + out.extend_from_slice(&1u16.to_le_bytes()); // PCM + out.extend_from_slice(&channels.to_le_bytes()); + out.extend_from_slice(&rate.to_le_bytes()); + out.extend_from_slice(&byte_rate.to_le_bytes()); + out.extend_from_slice(&block_align.to_le_bytes()); + out.extend_from_slice(&bits.to_le_bytes()); + + out.extend_from_slice(b"data"); + out.extend_from_slice(&(data_bytes as u32).to_le_bytes()); + for &s in samples { + out.extend_from_slice(&s.to_le_bytes()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_header_describes_the_payload() { + let samples = vec![0i16; 200]; + let wav = encode(44100, &samples); + assert_eq!(&wav[..4], b"RIFF"); + assert_eq!(&wav[8..12], b"WAVE"); + assert_eq!(wav.len(), 44 + samples.len() * 2); + // The RIFF size field counts everything after it. + let riff = u32::from_le_bytes([wav[4], wav[5], wav[6], wav[7]]) as usize; + assert_eq!(riff, wav.len() - 8); + // And the data chunk counts just the samples. + let data = u32::from_le_bytes([wav[40], wav[41], wav[42], wav[43]]) as usize; + assert_eq!(data, samples.len() * 2); + } +} diff --git a/package.json b/package.json index 4613dd7d..24234201 100644 --- a/package.json +++ b/package.json @@ -107,6 +107,7 @@ "mon": "bun tools/mon.ts", "mon:check": "bun tools/mon.ts check", "mon:shots": "bun tools/mon.ts shots", + "mon:audio": "bun tools/mon.ts audio", "mon:psp": "bun tools/mon.ts psp", "e2e:mon": "bun tests/e2e/mon-ppsspp.ts", "psp:all": "bun tools/psp-all.ts", diff --git a/tools/mon.ts b/tools/mon.ts index be078d84..d96b58c8 100644 --- a/tools/mon.ts +++ b/tools/mon.ts @@ -5,6 +5,7 @@ // bun tools/mon.ts check cook + run the story tape against goldens // bun tools/mon.ts record cook + re-record the golden hashes // bun tools/mon.ts shots [dir] cook + write a PNG per checkpoint +// bun tools/mon.ts audio [dir] cook + render every song and effect to WAV // bun tools/mon.ts psp [cargo args] cook, then build the PSP EBOOT // bun tools/mon.ts run …and launch it in PPSSPP // @@ -30,7 +31,7 @@ const rest = argv.slice(1); function usage(): never { console.error( - "usage: bun tools/mon.ts [args…]", + "usage: bun tools/mon.ts [args…]", ); process.exit(2); } @@ -89,6 +90,16 @@ async function main() { return; } + case "audio": { + await cook(); + const bin = await buildSim(); + const dir = rest[0] ?? `${root}dist/mon-audio`; + mkdirSync(dir, { recursive: true }); + await $`${bin} ${pakPath} --audio ${dir} --frames 1`; + console.log(`\nwrote WAVs to ${dir}`); + return; + } + case "psp": case "run": { await cook(); From e01c826f5becd7762326a4d8225a522a7ccbee5c Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sat, 25 Jul 2026 17:56:53 +0800 Subject: [PATCH 4/5] feat(mon): add the PSPLINK hardware loop, and clock the CPU up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bun tools/mon.ts hw` serves the build as host0: over usbhostfs_pc, ldstarts the PRX, and sits in an Enter-to-reload loop — the same shape tools/hw.ts uses for the 2D runtime, kept separate because the two share no paths and neither is complicated. Release by default. The debug PRX is 16 MB of symbols against 0.6 MB, and every reload pushes the whole thing over USB. Also sets the clock to 333/166 at boot. The PSP comes up at 222 MHz and PSPLINK leaves it there; the core is integer-only and cheap, but a 30x17 tile view is ~500 GE sprites a frame with a software mixer on a second thread, and the other 50% is free. Boot now prints the arena and kernel free-memory figures to the debug screen. The game overdraws them on its first frame, so they only linger when boot itself failed — which is exactly when you need to read them off a console with no debugger attached. `MON_E2E_PROFILE=release bun tests/e2e/mon-ppsspp.ts` runs the emulator suite against the build that ships to hardware. It passes byte-identically to the debug build, which is the claim worth having before plugging anything in. Still not run on a physical console. --- docs/MON.md | 11 +- .../crates/pocketmon-psp/src/main.rs | 14 +- package.json | 1 + tests/e2e/mon-ppsspp.ts | 8 +- tools/mon.ts | 169 +++++++++++++++++- 5 files changed, 197 insertions(+), 6 deletions(-) diff --git a/docs/MON.md b/docs/MON.md index dedff9f5..34018a6a 100644 --- a/docs/MON.md +++ b/docs/MON.md @@ -238,7 +238,16 @@ A runtime without a headless story is not done (discipline #4): the software rasterizer's own output. The GE path and the reference rasterizer are separate implementations of one draw list; if they disagree, the sim goldens are describing a picture the console never shows. -- Real hardware, over PSPLINK — **not yet run**. +- **`bun tools/mon.ts hw`** — real hardware over PSPLINK. Serves the release + build as `host0:` through `usbhostfs_pc`, `ldstart`s the PRX, and then sits + in an Enter-to-reload loop. Release by default: the debug PRX is 16 MB of + symbols against 0.6 MB, and every reload pushes it over USB. + + `MON_E2E_PROFILE=release bun tests/e2e/mon-ppsspp.ts` checks that build in + the emulator first — the profile should change optimisation, not output, and + the two currently render byte-identically. + + **Not yet run on a physical console.** Everything above is emulator. ## 7. Sound diff --git a/engine/pocketmon/crates/pocketmon-psp/src/main.rs b/engine/pocketmon/crates/pocketmon-psp/src/main.rs index 932e3019..fd7762da 100644 --- a/engine/pocketmon/crates/pocketmon-psp/src/main.rs +++ b/engine/pocketmon/crates/pocketmon-psp/src/main.rs @@ -74,6 +74,10 @@ const NUB_DEADZONE: i32 = 48; fn psp_main() { unsafe { psp::enable_home_button(); + // The PSP boots at 222 MHz and PSPLINK leaves it there. The core is + // integer-only and cheap, but a 30x17 tile view is ~500 GE sprites a + // frame plus a software mixer on a second thread — take the free 50%. + sys::scePowerSetClockFrequency(333, 333, 166); // Real hardware can start a PSPLINK-loaded thread with FPU exceptions // unmasked. The core is integer-only, but the SDK is not; clear FCSR // so a stray NaN in library code does not trap to a black screen. @@ -146,12 +150,20 @@ unsafe fn run() -> ! { let mut backend = Backend::new(); backend.upload(&game.content); + // Boot diagnostics on the debug screen. The game overdraws them on its + // first frame, so they only linger if boot itself failed — which is + // exactly when you want to read them off a real console. psp::dprintln!( - "[sparkwood] {} species, {} maps, {} pages, {} kB arena free", + "[sparkwood] {} species, {} maps, {} pages", game.content.species.len(), game.content.maps.len(), game.content.pages.len(), + ); + psp::dprintln!( + "[sparkwood] arena {} kB free of {} kB, kernel free {} kB", ALLOC.free_bytes() / 1024, + arena::ARENA_BYTES / 1024, + sys::sceKernelMaxFreeMemSize() / 1024, ); audio::start(Bank::from_content(&game.content)); diff --git a/package.json b/package.json index 24234201..024fa1ce 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "mon:shots": "bun tools/mon.ts shots", "mon:audio": "bun tools/mon.ts audio", "mon:psp": "bun tools/mon.ts psp", + "mon:hw": "bun tools/mon.ts hw", "e2e:mon": "bun tests/e2e/mon-ppsspp.ts", "psp:all": "bun tools/psp-all.ts", "psp:switch": "bun psplink", diff --git a/tests/e2e/mon-ppsspp.ts b/tests/e2e/mon-ppsspp.ts index 067e0ffc..88128a8c 100644 --- a/tests/e2e/mon-ppsspp.ts +++ b/tests/e2e/mon-ppsspp.ts @@ -27,7 +27,11 @@ const headless = process.env.PPSSPP_HEADLESS || `${homedir()}/ppsspp-src/build/P // PPSSPPHeadless maps ms0: to ~/.ppsspp — dumps land in ~/.ppsspp/mon_cap. // Contents persist across runs; always clean first. const capDir = `${homedir()}/.ppsspp/mon_cap`; -const eboot = `${root}engine/pocketmon/crates/pocketmon-psp/target/mipsel-sony-psp/debug/EBOOT.PBP`; +// `MON_E2E_PROFILE=release` checks the build that actually ships to hardware. +// Both must render identically — the profile changes optimisation, not output, +// and if it ever does change output that is the bug worth finding. +const profile = process.env.MON_E2E_PROFILE === "release" ? "release" : "debug"; +const eboot = `${root}engine/pocketmon/crates/pocketmon-psp/target/mipsel-sony-psp/${profile}/EBOOT.PBP`; const update = process.env.UPDATE === "1"; // --------------------------------------------------------------------------- @@ -83,7 +87,7 @@ mkdirSync(simShots, { recursive: true }); await $`${root}engine/pocketmon/crates/pocketmon-sim/target/release/pocketmon-sim ${root}dist/sparkwood.monpak --tape ${root}apps/mon/tapes/story.tape --shots ${simShots}`.quiet(); console.log("# build the capture EBOOT ..."); -await $`bun tools/mon.ts psp --features capture` +await $`bun tools/mon.ts psp --features capture ${profile === "release" ? ["--release"] : []}` .cwd(root) .env({ ...process.env, diff --git a/tools/mon.ts b/tools/mon.ts index d96b58c8..dea73bd5 100644 --- a/tools/mon.ts +++ b/tools/mon.ts @@ -8,6 +8,7 @@ // bun tools/mon.ts audio [dir] cook + render every song and effect to WAV // bun tools/mon.ts psp [cargo args] cook, then build the PSP EBOOT // bun tools/mon.ts run …and launch it in PPSSPP +// bun tools/mon.ts hw [--debug] …or load it onto a REAL PSP over PSPLINK // // The pak is cooked first by every subcommand on purpose: content lives in // TypeScript, and a stale pak is the one failure that looks like an engine bug @@ -15,6 +16,8 @@ import { $ } from "bun"; import { existsSync, mkdirSync } from "node:fs"; +import { createInterface } from "node:readline"; +import { createServer } from "node:net"; import { resolvePspBuildToolchain } from "./psp-toolchain.ts"; const root = new URL("..", import.meta.url).pathname; @@ -31,7 +34,7 @@ const rest = argv.slice(1); function usage(): never { console.error( - "usage: bun tools/mon.ts [args…]", + "usage: bun tools/mon.ts [args…]", ); process.exit(2); } @@ -108,6 +111,16 @@ async function main() { return; } + case "hw": { + // Release by default: the debug PRX is 16 MB of symbols against 0.6 MB, + // and every reload pushes it over USB. + const debug = rest.includes("--debug"); + await cook(); + await buildEboot(debug ? [] : ["--release"]); + await runOnHardware(debug ? "debug" : "release"); + return; + } + default: usage(); } @@ -175,10 +188,162 @@ async function buildEboot(cargoArgs: string[]): Promise { .cwd(eboot) .env(env); - const out = `${eboot}/target/mipsel-sony-psp/debug/EBOOT.PBP`; + const profile = cargoArgs.includes("--release") ? "release" : "debug"; + const out = `${eboot}/target/mipsel-sony-psp/${profile}/EBOOT.PBP`; if (existsSync(out)) console.log(`\nEBOOT: ${out}`); } +// --------------------------------------------------------------------------- +// Real hardware, over PSPLINK +// --------------------------------------------------------------------------- + +/** + * Serve the build directory as `host0:` over USB and `ldstart` the PRX, then + * sit in a rebuild/reload loop. + * + * Same shape as tools/hw.ts (which does this for the 2D runtime); kept here + * rather than generalised because the two differ in every path and neither is + * complicated. + */ +async function runOnHardware(profile: string): Promise { + const usbhostfs = Bun.which("usbhostfs_pc"); + const pspsh = Bun.which("pspsh"); + if (!usbhostfs || !pspsh) { + console.error( + "PSPLINK host tools not on PATH (need usbhostfs_pc and pspsh).\n" + + " brew install pspdev/pspdev/pspsdk # or build psplink from source", + ); + process.exit(1); + } + + const targetDir = `${eboot}/target/mipsel-sony-psp/${profile}`; + const prx = `${targetDir}/pocketmon-psp.prx`; + if (!existsSync(prx)) { + console.error(`no PRX at ${prx}`); + process.exit(1); + } + + const basePort = await findBasePort(Number(process.env.PSP_HW_PORT ?? 10300)); + const running = (await $`pgrep -x usbhostfs_pc`.nothrow().text()).trim(); + if (running) { + console.log( + `note: another usbhostfs_pc is running (pid ${running.split("\n").join(", ")}).\n` + + " Only one can own the PSP's USB — kill it if this link does not connect.", + ); + } + + console.log(`serving ${profile} build as host0: on port ${basePort}`); + const server = Bun.spawn([usbhostfs, "-b", String(basePort), targetDir], { + stdout: "pipe", + stderr: "pipe", + }); + + // usbhostfs_pc announces every (re)connection; counting them is how we know + // a reset actually came back rather than hanging. + let connects = 0; + const pump = async (stream: ReadableStream) => { + const dec = new TextDecoder(); + for await (const chunk of stream) { + for (const _ of dec.decode(chunk).matchAll(/Connected to device/g)) connects++; + } + }; + void pump(server.stdout); + void pump(server.stderr); + + const stop = () => { + try { + server.kill(); + process.kill(server.pid, "SIGKILL"); + } catch { + // already gone + } + }; + process.on("SIGINT", () => { + stop(); + process.exit(0); + }); + + const waitForConnect = async (from: number, timeoutMs: number): Promise => { + const t0 = Date.now(); + while (connects <= from) { + if (Date.now() - t0 > timeoutMs) return false; + await Bun.sleep(200); + } + return true; + }; + + const sh = async (command: string, timeoutMs = 10_000): Promise => { + const child = Bun.spawn([pspsh, "-p", String(basePort), "-e", command], { + stdout: "pipe", + stderr: "pipe", + }); + const timer = setTimeout(() => child.kill(), timeoutMs); + const [out, err] = await Promise.all([ + new Response(child.stdout).text(), + new Response(child.stderr).text(), + child.exited, + ]); + clearTimeout(timer); + return (out + err).trim(); + }; + + console.log("waiting for the PSP — launch PSPLINK on it (XMB -> Game -> PSPLINK)."); + if (!(await waitForConnect(0, 120_000))) { + console.error("PSP never connected. Check the USB DATA cable and that PSPLINK is running."); + stop(); + process.exit(1); + } + console.log("PSP connected."); + + const load = async (): Promise => { + const before = connects; + process.stdout.write("resetting PSPLINK... "); + await sh("reset", 6000); + console.log((await waitForConnect(before, 15_000)) ? "connected." : "no reconnect; continuing."); + const out = await sh("ldstart host0:/pocketmon-psp.prx"); + console.log(" " + (out || "(no output)")); + if (/Failed|Error/i.test(out)) { + console.log(" load failed — is PSPLINK still running and host0: mounted?"); + } + }; + + await load(); + console.log("\n[mon:hw] Enter = rebuild + reload | q + Enter = quit\n"); + const rl = createInterface({ input: process.stdin }); + for await (const line of rl) { + const cmd = line.trim().toLowerCase(); + if (cmd === "q" || cmd === "quit" || cmd === "exit") break; + await cook(); + await buildEboot(profile === "release" ? ["--release"] : []); + await load(); + console.log("\n[mon:hw] Enter = rebuild + reload | q + Enter = quit\n"); + } + rl.close(); + stop(); +} + +/** A block of consecutive free ports for the PSPLINK link. */ +async function findBasePort(start: number): Promise { + const free = (port: number) => + new Promise((resolve) => { + const srv = createServer(); + srv.once("error", () => resolve(false)); + srv.once("listening", () => srv.close(() => resolve(true))); + srv.listen(port, "127.0.0.1"); + }); + for (let base = start; base <= start + 3000; base += 100) { + let ok = true; + for (let i = 0; i <= 8; i++) { + if (!(await free(base + i))) { + ok = false; + break; + } + } + if (ok) return base; + } + throw new Error("no free TCP port block for the PSPLINK link"); +} + async function launchPpsspp(): Promise { const candidates = [ "/Applications/PPSSPPSDL.app/Contents/MacOS/PPSSPPSDL", From 98f035daf5512764a0edf1ed990c72cf7523077d Mon Sep 17 00:00:00 2001 From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com> Date: Sat, 25 Jul 2026 18:09:09 +0800 Subject: [PATCH 5/5] fix(mon): CIRCLE confirms, matching every other PocketJS host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The EBOOT had CROSS as confirm on a Western-market rationale. That is wrong for this repo: framework/src/input.ts presses the focused node on CIRCLE, and every other PocketJS title does the same. A player with the family already on their memory stick would reach this one game and find the confirm button dead. Caught while copying the EBOOT onto a real stick alongside a dozen other PocketJS folders — which is the only place the inconsistency is visible. The capture build drives the core's abstract button set directly and never goes through this mapping, so the frame goldens are unaffected; re-ran them to confirm. --- .../pocketmon/crates/pocketmon-psp/src/main.rs | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/engine/pocketmon/crates/pocketmon-psp/src/main.rs b/engine/pocketmon/crates/pocketmon-psp/src/main.rs index fd7762da..9ed9c0f1 100644 --- a/engine/pocketmon/crates/pocketmon-psp/src/main.rs +++ b/engine/pocketmon/crates/pocketmon-psp/src/main.rs @@ -9,14 +9,16 @@ //! //! ```text //! D-pad / analog walk, move a cursor -//! CROSS confirm (A) -//! CIRCLE cancel (B) +//! CIRCLE confirm (A) +//! CROSS cancel (B) //! START menu -//! SELECT the debug overlay //! ``` //! -//! CROSS is confirm rather than CIRCLE: this is a Western-market handheld -//! convention and matches every other PocketJS host in the repo. +//! CIRCLE confirms, not CROSS. That is the Japanese-market convention the PSP +//! shipped with, and — the reason it matters here — it is what every other +//! PocketJS host does (`framework/src/input.ts` presses the focused node on +//! CIRCLE). A player with the rest of the family on their memory stick would +//! otherwise reach this one game and find the confirm button dead. #![no_std] #![no_main] @@ -222,10 +224,10 @@ fn map_buttons(pad: &SceCtrlData) -> u32 { if b.contains(CtrlButtons::RIGHT) { mask |= spec::btn::RIGHT; } - if b.contains(CtrlButtons::CROSS) { + if b.contains(CtrlButtons::CIRCLE) { mask |= spec::btn::A; } - if b.contains(CtrlButtons::CIRCLE) { + if b.contains(CtrlButtons::CROSS) { mask |= spec::btn::B; } if b.contains(CtrlButtons::START) {