feat(mon): port the Gen1 recomp architecture to a PocketJS runtime on PSP#184
Draft
doodlewind wants to merge 5 commits into
Draft
feat(mon): port the Gen1 recomp architecture to a PocketJS runtime on PSP#184doodlewind wants to merge 5 commits into
doodlewind wants to merge 5 commits into
Conversation
… 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 indocs/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-coremonsurface dispatcher.no_std+ alloc, zero dependencies, zero floating point.engine/pocketmon/crates/pocketmon-guengine/pocketmon/crates/pocketmon-pspengine/pocketmon/crates/pocketmon-simcontracts/spec/mon-spec.tsapps/monVerification
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.ts— the same journey on the console. There is no second tape:pocketmon-sim --emit-pspreplays 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:vapor/precedent, not thehosts/pspone — which is what keeps the whole runtime to Rust + TypeScript with no C in the tree. Themonsurface is specified and implemented (Game::op, one dispatcher, covered bycargo test) with a guest SDK inapps/mon/sdk.ts, so mounting a realm later is a thin binding over an existing boundary. A decision left open, not an oversight.Four bugs worth calling out
All found by a harness and fixed at the source, not worked around:
#[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 aconstassertion.bun tools/mon.ts audiorenders the score to WAV so a human can check the tune.🤖 Generated with Claude Code