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..d5cef7d4 --- /dev/null +++ b/apps/mon/content/game.ts @@ -0,0 +1,776 @@ +// 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, + music: 1, + 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, + music: 1, + 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/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/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..b3ae62bd --- /dev/null +++ b/apps/mon/cook.ts @@ -0,0 +1,633 @@ +// 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 { 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"; +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; +} + +/** + * 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(); + 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() }, + { 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) }); + + 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)`, + ` ${SONGS.length} songs, ${SFX.length} sound effects`, + ].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..d5c7c082 --- /dev/null +++ b/apps/mon/tapes/story.tape @@ -0,0 +1,77 @@ +# 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 + +# 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 ---------------------------------------------------- +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 05-back-outside +walk d 4 +walk l 3 +walk u 9 +walk u 1 +wait 40 +mark 06-route-one + +# --- the first wild battle ------------------------------------------------ +walk u 3 +mark 07-in-the-grass +grind ud 900 +wait 20 +mark 08-wild-appears +clear +mark 09-action-menu +press a +wait 20 +mark 10-move-menu +fight +mark 11-battle-over + +# --- and again, to prove the loop repeats --------------------------------- +grind ud 900 +wait 20 +mark 12-second-encounter +fight +mark 13-still-standing diff --git a/contracts/spec/gen-mon-rust.ts b/contracts/spec/gen-mon-rust.ts new file mode 100644 index 00000000..4abd0695 --- /dev/null +++ b/contracts/spec/gen-mon-rust.ts @@ -0,0 +1,443 @@ +// 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_BUFFER, + AUDIO_CELL_SIZE, + AUDIO_CHANNELS, + AUDIO_ENTRY_SIZE, + AUDIO_HEADER_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, + NOTE_HOLD, + NOTE_OFF, + 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, + SAMPLE_RATE, + 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("/// 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."]); + + // --- 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..a8aedf3a --- /dev/null +++ b/contracts/spec/mon-spec.ts @@ -0,0 +1,798 @@ +// 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 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 +// --------------------------------------------------------------------------- + +/** + * 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..34018a6a --- /dev/null +++ b/docs/MON.md @@ -0,0 +1,282 @@ +# 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. + +**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 + +| 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` | **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 + +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**. + 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. +- **`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 + +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 +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/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/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..3de41885 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/lib.rs @@ -0,0 +1,731 @@ +//! 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 audio; +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; + +/// 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}; +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, + /// 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. + 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, + audio_seq: 0, + 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.emit_world_audio(); + 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; + } + + /// 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) { + 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..068f9d69 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-core/src/spec.rs @@ -0,0 +1,528 @@ +//! 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; +/// 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 { + 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/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/capture.rs b/engine/pocketmon/crates/pocketmon-psp/src/capture.rs new file mode 100644 index 00000000..58dc82e8 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-psp/src/capture.rs @@ -0,0 +1,153 @@ +//! 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"); +/// 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; + 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 + } +} + +/// 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. +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 if this frame is a checkpoint, and exit the +/// game once the run is over. +pub unsafe fn dump_frame(frame: u32) { + if frame == 0 { + sys::sceIoMkdir(b"ms0:/mon_cap\0".as_ptr(), 0o777); + } + 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; + } + + // 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); + } + } + if frame >= env_u32(EXIT_AT, 600) { + 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..9ed9c0f1 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-psp/src/main.rs @@ -0,0 +1,252 @@ +//! 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 +//! CIRCLE confirm (A) +//! CROSS cancel (B) +//! START menu +//! ``` +//! +//! 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] +// 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; +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; + +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(); + // 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. + 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); + + // 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", + 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)); + + // 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(); + audio::post(game.audio_seq, game.music, game.sfx, game.cry); + 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::CIRCLE) { + mask |= spec::btn::A; + } + if b.contains(CtrlButtons::CROSS) { + 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..59288843 --- /dev/null +++ b/engine/pocketmon/crates/pocketmon-sim/src/main.rs @@ -0,0 +1,737 @@ +//! 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; +mod wav; + +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 audio_dir: Option = None; + let mut emit_psp: 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); + } + "--audio" => { + i += 1; + audio_dir = args.get(i).map(PathBuf::from); + } + "--atlas" => { + 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() { + 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()); + } + + // 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); + + 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; + // 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; + } + } + 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); + input_log.push(*buttons); + total += 1; + } + for _ in 0..8 { + game.tick(0); + input_log.push(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); + input_log.push(*dir); + total += 1; + spent += 1; + if game.world.map_id != start_map { + break; + } + } + 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!( + " !! 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); + input_log.push(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); + input_log.push(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_logged(&mut game, &mut input_log); + } + 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); + input_log.push(button); + total += 1; + } + for _ in 0..12 { + game.tick(0); + input_log.push(0); + total += 1; + } + acted += 1; + } + if game.battle.is_some() { + println!(" !! fight did not finish in {acted} actions"); + stalled += 1; + } + total += settle_logged(&mut game, &mut input_log); + } + 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); + input_log.push(dir); + total += 1; + spent += 1; + stuck += 1; + } + let settled = settle_logged(&mut game, &mut input_log); + 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()); + 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); + 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"); + } + // 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); + } 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, + } +} + +/// [`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; + 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); + } +} + +/// 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 { + 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/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 bc1355c7..024fa1ce 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,13 @@ "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: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", "vita": "bun tools/vita.ts", @@ -128,7 +135,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..88128a8c --- /dev/null +++ b/tests/e2e/mon-ppsspp.ts @@ -0,0 +1,186 @@ +// 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`; +// `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"; + +// --------------------------------------------------------------------------- +// The run. +// +// 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. +// +// 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. +// --------------------------------------------------------------------------- + +interface Plan { + frames: number; + input: string; + shots: Array<{ name: string; frame: number }>; +} + +// --------------------------------------------------------------------------- + +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("# 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 ${profile === "release" ? ["--release"] : []}` + .cwd(root) + .env({ + ...process.env, + 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 || 240); +const run = await $`${headless} --graphics=software --timeout=${timeout} ${eboot}` + .cwd("/tmp") + .nothrow() + .quiet(); + +// 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 !== shots.length) { + console.error( + `FAIL: dumped ${produced}/${shots.length} checkpoints within ${timeout}s.\n` + + `PPSSPP output:\n${run.stdout}${run.stderr}`, + ); + process.exit(1); +} +console.log(`liveness: ${produced}/${shots.length} checkpoints reached on the console`); + +let failed = false; +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. + 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(); + + // 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(); + 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/00-wake.png b/tests/goldens/mon/psp/00-wake.png new file mode 100644 index 00000000..a054a3b0 Binary files /dev/null and b/tests/goldens/mon/psp/00-wake.png differ diff --git a/tests/goldens/mon/psp/01-village.png b/tests/goldens/mon/psp/01-village.png new file mode 100644 index 00000000..38e684f3 Binary files /dev/null and b/tests/goldens/mon/psp/01-village.png differ 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 00000000..fc977def Binary files /dev/null and b/tests/goldens/mon/psp/02-inside-lab.png differ diff --git a/tests/goldens/mon/psp/03-facing-professor.png b/tests/goldens/mon/psp/03-facing-professor.png new file mode 100644 index 00000000..c92389e9 Binary files /dev/null and b/tests/goldens/mon/psp/03-facing-professor.png differ diff --git a/tests/goldens/mon/psp/04-partner-in-hand.png b/tests/goldens/mon/psp/04-partner-in-hand.png new file mode 100644 index 00000000..c92389e9 Binary files /dev/null and b/tests/goldens/mon/psp/04-partner-in-hand.png differ diff --git a/tests/goldens/mon/psp/05-back-outside.png b/tests/goldens/mon/psp/05-back-outside.png new file mode 100644 index 00000000..662a0b6d Binary files /dev/null and b/tests/goldens/mon/psp/05-back-outside.png differ 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 00000000..b106f9b8 Binary files /dev/null and b/tests/goldens/mon/psp/06-route-one.png differ 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 00000000..33dcbe5d Binary files /dev/null and b/tests/goldens/mon/psp/07-in-the-grass.png differ 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 00000000..06c6e987 Binary files /dev/null and b/tests/goldens/mon/psp/08-wild-appears.png differ 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 00000000..f7ba3825 Binary files /dev/null and b/tests/goldens/mon/psp/09-action-menu.png differ 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 00000000..3055fd5e Binary files /dev/null and b/tests/goldens/mon/psp/10-move-menu.png differ 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 00000000..450700c4 Binary files /dev/null and b/tests/goldens/mon/psp/11-battle-over.png differ 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 00000000..2f06bcd8 Binary files /dev/null and b/tests/goldens/mon/psp/12-second-encounter.png differ diff --git a/tests/goldens/mon/psp/13-still-standing.png b/tests/goldens/mon/psp/13-still-standing.png new file mode 100644 index 00000000..205d4cc6 Binary files /dev/null and b/tests/goldens/mon/psp/13-still-standing.png differ diff --git a/tests/goldens/mon/story.hashes b/tests/goldens/mon/story.hashes new file mode 100644 index 00000000..06de9855 --- /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 +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/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..dea73bd5 --- /dev/null +++ b/tools/mon.ts @@ -0,0 +1,365 @@ +// 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 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 +// (see the repo-wide "stale dist is target-flavored" lesson). + +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; +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 "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(); + await buildEboot(rest); + if (cmd === "run") await launchPpsspp(); + 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(); + } +} + +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_FRAMES: process.env.MON_CAP_FRAMES ?? "", + MON_CAP_EXIT: process.env.MON_CAP_EXIT ?? "600", + }; + + console.log("pocket mon: cargo psp"); + await $`${toolchain.rustup} run ${toolchain.manifest.rust.toolchain} cargo psp ${cargoArgs}` + .cwd(eboot) + .env(env); + + 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", + "/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();