Skip to content

feat(mon): port the Gen1 recomp architecture to a PocketJS runtime on PSP#184

Draft
doodlewind wants to merge 5 commits into
mainfrom
feat/pocketmon-runtime
Draft

feat(mon): port the Gen1 recomp architecture to a PocketJS runtime on PSP#184
doodlewind wants to merge 5 commits into
mainfrom
feat/pocketmon-runtime

Conversation

@doodlewind

@doodlewind doodlewind commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Ports bryanthaboi/pokemon-gen1-recomp-project — a 44 kLOC Lua/LÖVE2D recreation of a Gen 1 creature RPG — into the PocketJS ecosystem as its own specialized runtime, built on the Cores + Surfaces + Guest pattern in docs/RUNTIMES.md, and running on a PSP.

Rust and TypeScript only. No C anywhere in the tree.

The clean-room boundary

The upstream project is a shell: it ships no content and reconstructs graphics, maps, species tables, text and audio by decoding a player-supplied Game Boy ROM at first boot. This port has no counterpart to that layer — no ROM reader, no SHA-1 gate, no importer, no cache — and a test enforces it (tests/mon-content.test.ts, "the runtime has no path that reads a ROM").

What crosses over is mechanics: the damage formula, stat and growth curves, the encounter roll, the catch algorithm, the bottom-left-tile collision rule. Each ported rule keeps its provenance citation in the Rust source.

What ships instead is SPARKWOOD — original creatures, world, dialogue, art and music, authored in TypeScript, playable from a clean checkout.

What is here

engine/pocketmon/crates/pocketmon-core The RPG core. Grid world with the bottom-left-tile rule and stitched map connections; a full turn-battle engine (damage/crit/accuracy, type chart, 30 move effects, status, catching, experience, trainer AI); a resumable 41-verb script VM; a four-voice chip synth; a checksummed binary save; text engine; scene builder; the mon surface dispatcher. no_std + alloc, zero dependencies, zero floating point.
engine/pocketmon/crates/pocketmon-gu The sceGu backend for the core's draw list.
engine/pocketmon/crates/pocketmon-psp The EBOOT: arena allocator, frame loop, input mapping, an audio thread, and a capture build.
engine/pocketmon/crates/pocketmon-sim The headless host: same core, a reference software rasterizer, PNG and WAV output, and an intent-based tape format.
contracts/spec/mon-spec.ts The surface, codegen'd to Rust with a byte-compare drift guard.
apps/mon SPARKWOOD: content, procedural art, the score, the cooker, the guest SDK.

Verification

  • 261 core tests — the formula matrix (crit / STAB / dual-type floors / the byte-overflow stat rule), roll distributions, growth curves inverted against their own thresholds, collision, seam round-trips, the script VM's blocking verbs, save corruption rejection, the synth's tuning (octaves double exactly, A4 lands on 440.000 Hz), and a fuzz that every battle terminates across 60 seeds.

  • 36 contract + content tests — spec drift, and integrity checks that catch what is invisible on screen: a warp whose block has no door under it, a connection offset that is not mirrored, a species with no level-5 move, a character the font does not carry.

  • bun tools/mon.ts check — a deterministic playthrough (out of the house → across the village → take a starter from the professor → north to Route One → win two wild battles) asserted against recorded frame hashes.

  • bun tests/e2e/mon-ppsspp.tsthe same journey on the console. There is no second tape: pocketmon-sim --emit-psp replays the intent tape and writes out the per-frame input it produced, which the capture EBOOT replays verbatim. Sound only because the core is identical and deterministic on both hosts — the runtime's premise, cashed in.

    Asserts three things: liveness (14/14 checkpoints, 2877 frames, boot through two won battles), byte-exact PNG goldens, and backend agreement — every checkpoint compared pixel for pixel against the software rasterizer's own output. All 14 agree exactly.

  • The repo's existing suite: 462 tests, unchanged.

Not done

Stated plainly in docs/MON.md §2 rather than implied away:

  • No QuickJS realm is mounted in the EBOOT. TypeScript here is ahead-of-time compiled rather than interpreted — the vapor/ precedent, not the hosts/psp one — which is what keeps the whole runtime to Rust + TypeScript with no C in the tree. The mon surface is specified and implemented (Game::op, one dispatcher, covered by cargo test) with a guest SDK in apps/mon/sdk.ts, so mounting a realm later is a thin binding over an existing boundary. A decision left open, not an oversight.
  • Real hardware is untested. Emulator only so far.
  • Link play, the save editor and the mod-manager UI are out of scope for v1.

Four bugs worth calling out

All found by a harness and fixed at the source, not worked around:

  • Events re-dispatched every tick. The core reacts to some of its own facts (an encounter starts a battle, a talk starts a script) while the guest drains the same batch. Without a watermark it re-handled every event for as long as the batch sat undrained — which reopened a conversation the moment you closed it, forever.
  • A 14-byte GE vertex. #[repr(C, packed)] gives a 14-byte textured vertex; the GE pads to 16. It does not crash — it draws a screen of plausible-looking garbage, because every vertex after the first reads two bytes into its predecessor. Now a const assertion.
  • The frame a script ends was a frame you could act on. A script's last instruction runs in the same frame as the button press that released it, so closing a conversation with A immediately reopened it. The world gate now reads the script state from the top of the frame.
  • The audio mix peaked at 0.3% of full scale, having been gain-staged as though the output were 8-bit. Every unit test passed, because they all asserted "not silent" rather than "audible". They now assert a floor, and bun tools/mon.ts audio renders the score to WAV so a human can check the tune.

🤖 Generated with Claude Code

… PSP

Ports bryanthaboi/pokemon-gen1-recomp-project — a 44 kLOC Lua/LÖVE2D
recreation of a Gen 1 creature RPG — into the PocketJS ecosystem as its
own specialized runtime, following the Cores + Surfaces + Guest pattern
docs/RUNTIMES.md lays out, and ships it running on a PSP.

What is ported is that project's engine decomposition and its documented
Gen-1 behavioural rules. What is deliberately NOT ported is its content
path: the upstream project reconstructs everything by decoding a
player-supplied Game Boy ROM, and this runtime has no counterpart to
that layer, no code that can read one, and a test that keeps it that way
(docs/MON.md §1). The game that ships instead is SPARKWOOD — original
creatures, world, words and art, all authored in TypeScript.

  engine/pocketmon/crates/pocketmon-core   the RPG core: grid world with
      the bottom-left-tile collision rule and stitched map connections,
      a full turn-battle engine (damage/crit/accuracy, type chart,
      30 move effects, status, catching, experience, trainer AI), a
      resumable 41-verb script VM, a checksummed binary save, the text
      engine, the scene builder, and the `mon` surface's op dispatcher.
      no_std + alloc, zero dependencies, zero floating point.
  engine/pocketmon/crates/pocketmon-gu     the GE backend for its draw list
  engine/pocketmon/crates/pocketmon-psp    the EBOOT: arena allocator,
      frame loop, input mapping, and a capture build for the goldens
  engine/pocketmon/crates/pocketmon-sim    the headless host: the same
      core, a reference software rasterizer, and an intent-based tape
      format ("walk d 4", not a frame count)
  contracts/spec/mon-spec.ts               the surface, codegen'd to Rust
      with a byte-compare drift guard
  apps/mon                                 SPARKWOOD: content, procedural
      art, the cooker, the guest SDK

Verified: 245 core tests, 36 contract/content tests, a deterministic
playthrough asserted against frame hashes (out of the house, across the
village, take a starter, north to Route One, win two wild battles), and
the same journey on the console under PPSSPPHeadless with byte-exact PNG
goldens — 300/300 frames presented, boot through warp.

Not done, and stated as such in docs/MON.md §2: no QuickJS realm is
mounted in the EBOOT yet, so the surface has no JS caller on the console;
audio is specified but unimplemented; real hardware is untested.
The PSP e2e had its own hand-written, frame-counted input script, which
described a shorter journey than the sim tape and would have drifted from
it the first time a walk cadence changed. Two descriptions of one journey
is one too many.

`pocketmon-sim --emit-psp` now records the per-frame input its intent
tape produced and writes it out with the frame each checkpoint landed on;
the capture EBOOT replays that verbatim. This is only sound because the
core is identical and deterministic on both hosts — the property the
runtime is built around — so the console now walks the whole story: out
of the house, across the village, the professor's script, the seam into
Route One, and two won wild battles. 14/14 checkpoints, 2877 frames.

Also asserts backend agreement: every checkpoint is compared pixel for
pixel against the software rasterizer's own PNG. The GE path and the
reference rasterizer are separate implementations of one draw list, and
without this the sim goldens could describe a picture the console never
shows. All 14 currently agree exactly.

Two fixes fell out of building it:
  - a `settle` call in the `clear` command was not recording its frames,
    so the replay log and the frame counter disagreed by four and the
    last checkpoint fell past the end of the run. The emit path now takes
    the frame number FROM the log, and the sim hard-fails if any tick
    escapes it.
  - the sim can render before it has ticked at all and a console cannot,
    so the tape now spends one frame before its first checkpoint. That
    was the only pixel difference between the two backends.
The last subsystem the upstream project has that this port did not:
ChipSynth.lua and ChipAudio.lua, minus the half of those files that
exists to decode channel programs out of a ROM.

Four voices — two pulse with selectable duty and a volume envelope, a
wave channel over a 32-step 4-bit table, and a noise channel on a 15-bit
LFSR. Integer only like the rest of the core: phase is a 16.16
accumulator and notes are frequencies in millihertz, because a synth that
rounds differently on two hosts is a bug that gets blamed on something
else. Octaves double exactly and A4 lands on 440.000 Hz, both asserted.

Tracks are tracker patterns in `apps/mon/content/music.ts` — four
channels, one cell per row, tempo in rows per minute. That is the shape a
four-voice chip wants and the shape a person can read back, which matters
when the score is hand-authored rather than extracted. Three songs (the
village, Route One, and a battle theme) and five effects.

The core renders on demand and never touches a device. On the PSP the
host is a dedicated thread: `sceAudioOutputBlocking` at 1024 samples
paces at ~43 Hz and would cap the frame loop below 60 if it ran inline.
The frame loop posts through three atomics and a sequence counter — the
smallest shared surface that works, and no lock.

`bun tools/mon.ts audio` renders every track to WAV, which is how the
gain-staging bug got caught: the mix was scaled as though the output were
8-bit and peaked at 84 of 32767. Every unit test passed, because they all
asserted "not silent" rather than "audible". They now assert a floor.
`bun tools/mon.ts hw` serves the build as host0: over usbhostfs_pc,
ldstarts the PRX, and sits in an Enter-to-reload loop — the same shape
tools/hw.ts uses for the 2D runtime, kept separate because the two share
no paths and neither is complicated.

Release by default. The debug PRX is 16 MB of symbols against 0.6 MB, and
every reload pushes the whole thing over USB.

Also sets the clock to 333/166 at boot. The PSP comes up at 222 MHz and
PSPLINK leaves it there; the core is integer-only and cheap, but a 30x17
tile view is ~500 GE sprites a frame with a software mixer on a second
thread, and the other 50% is free.

Boot now prints the arena and kernel free-memory figures to the debug
screen. The game overdraws them on its first frame, so they only linger
when boot itself failed — which is exactly when you need to read them off
a console with no debugger attached.

`MON_E2E_PROFILE=release bun tests/e2e/mon-ppsspp.ts` runs the emulator
suite against the build that ships to hardware. It passes byte-identically
to the debug build, which is the claim worth having before plugging
anything in.

Still not run on a physical console.
The EBOOT had CROSS as confirm on a Western-market rationale. That is
wrong for this repo: framework/src/input.ts presses the focused node on
CIRCLE, and every other PocketJS title does the same. A player with the
family already on their memory stick would reach this one game and find
the confirm button dead.

Caught while copying the EBOOT onto a real stick alongside a dozen other
PocketJS folders — which is the only place the inconsistency is visible.

The capture build drives the core's abstract button set directly and
never goes through this mapping, so the frame goldens are unaffected;
re-ran them to confirm.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant