Add native Crystal exploration port and reference tooling
Checks / web (push) Canceled after 0s

This commit is contained in:
Sébastien QUEROL committed 2026-09-23 09:23:29 +02:00
1 parent 2127f5f459
commit 8b80d05ca2
56 files changed
+6910 -1

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
name: Checks
on: [push, pull_request]
permissions:
contents: read
jobs:
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run check:vendor
- run: npm test
- run: npm run build
- run: npm run check:release
- run: npm run build:reference
- run: npx playwright install --with-deps chromium webkit
- run: npm run test:e2e
+14
View File
@@ -0,0 +1,14 @@
node_modules/
dist/
test-results/
playwright-report/
.DS_Store
*.gb
*.gbc
*.rom
*.sav
*.crystal-save.json
.env*
.reference/
artifacts/
dist-reference/
+1
View File
@@ -0,0 +1 @@
22
+47 -1
View File
@@ -1,3 +1,49 @@
# Crystal
Decompilation of *Pokemon Crystal version* by **PRET** : https://pret.github.io/pokecrystal/ - https://github.com/pret/pokecrystal
A native web port of Pokémon Crystal, translated from [PRET's Game Boy disassembly](https://github.com/pret/pokecrystal). The game runs in TypeScript and reads graphics, maps, sprites and dialogue from the user's own ROM. No original game assets are included.
**Current milestone: New Bark Town exploration.** Walk around your room, downstairs, town and its buildings. Read signs, use the bedroom radio, try supported conversations, and save your progress. Opening scenes, story progression, battles, NPC movement, decorations, audio and the rest of the world are still being ported.
## Run
Requires Node.js 22.12 or newer.
```sh
npm ci
npm run dev
```
Open the printed local URL, choose a name/character and select your uncompressed **English US/Europe revision 1.1** ROM (`.gbc`, 2 MiB). Its checksum is verified locally. ROMs are never uploaded or stored in browser storage; select yours again after reloading to resume.
Use onscreen controls, or focus the game screen and use arrows to move, **X** to interact and **X / Z** to continue dialogue. Sessions save every 10 seconds and when paused. Switching apps pauses play. Download a backup before clearing browser data.
Native saves are separate from emulator snapshots and cartridge `.sav` files. Time-of-day colors follow the device clock. The tile counter measures in-game movement, not real-world steps.
## Reference emulator
During development, open **`/reference.html`** to run the original game for comparison. It has separate saves and does not run inside the native player. Reference tools and the emulator are excluded from the default build.
```sh
npm run build # Native application in dist/
npm run check:release # Check the native distribution
npm run build:reference # Optional reference in dist-reference/
```
Serve `dist/` through an HTTPS static host, or use `npm run preview` locally. Subdirectory hosting is supported with `npm run build -- --base=/Crystal/`. Use HTTPS from another device; ROM verification and save locking require a secure context.
## Development
```sh
npm test
npx playwright install chromium webkit
npm run test:e2e
npm run check:vendor
```
Local-ROM tests use `Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc` at the root, or `CRYSTAL_ROM_PATH`. They skip when absent; no test downloads a ROM. Game files, private reference builds and gameplay screenshots must remain ignored.
The [full port plan](docs/PLAN.md) covers remaining game systems, mobile UI, internet Link and walking. See the [architecture](docs/ARCHITECTURE.md) and [routine map and verification](docs/NATIVE_PORT.md) for module boundaries and reference checks.
References: PRET [documentation](https://pret.github.io/pokecrystal/), [wiki](https://github.com/pret/pokecrystal/wiki) and [source](https://github.com/pret/pokecrystal). The development reference uses [binjgb](https://github.com/binji/binjgb) under its [MIT license](reference/public/vendor/binjgb/LICENSE); see [provenance](reference/public/vendor/binjgb/PROVENANCE.md).
Independent project. Not affiliated with Nintendo or Game Freak.
+44
View File
@@ -0,0 +1,44 @@
# Architecture
The default application is a native TypeScript game. It loads no CPU interpreter, Game Boy hardware model or WebAssembly emulator. The reference emulator has a separate entry point and build.
```mermaid
flowchart LR
ROM[User ROM] --> Gate[Exact revision validation]
Gate --> Assets[ROM reader and asset decoders]
Assets --> World[Native world rules]
Assets --> Scripts[Native event and text interpreters]
Scripts --> World
Input[Touch and keyboard] --> Runtime[Native runtime]
Runtime --> World
World --> View[Canvas world and DOM dialogue]
Session[Application session] --> Runtime
Session --> Saves[Native save storage]
Reference[Separate reference emulator] -. local comparisons only .-> World
```
| Module | Responsibility |
| ------------------------------- | ------------------------------------------------------------------------------------------- |
| `src/rom/validate.ts` | Exact cartridge identity, no networking |
| `src/game/rom` | Banked reads, LZ, maps/tilesets/sprites/palettes and pinned addresses |
| `src/game/world` | Position, movement, collision, warps, visibility, time selection and native save validation |
| `src/game/scripts` | The game's event/text formats, no GB instructions |
| `src/game/rendering` | Planar pixels, metatiles, camera and sprites |
| `src/game/runtime.ts` | Frame scheduling, input, interpolation and presentation updates |
| `src/input` | Abstract buttons, multiple sources and browser bindings |
| `src/app` | Runtime-independent load/play/pause/backup lifecycle |
| `src/storage` | Versioned envelope, IndexedDB transactions and ordered writes |
| `src/ui`, `src/main.ts` | UI composition and DOM dialogue, no ROM offsets |
| `src/platform` | Browser locks and future native platform adapters |
| `src/reference`, `src/emulator` | Original-game reference, excluded from the native build |
| `reference/public` | Licensed emulator files for the reference build only |
`GameRuntime` is a lifecycle interface, not an emulator contract. Native state is validated JSON: map, position, facing, name/avatar, in-game tile count, flags and sprite variables. It contains no graphics, music, dialogue, GB memory or ROM bytes. The backup envelope retains the historical `coreId` field; native backups use it for their runtime/schema ID.
Native and reference sessions use different IndexedDB databases and Web Locks. Imports reject incompatible schemas and invalid positions before changing the world. A failed import restores the prior state. Original cartridge saves and emulator snapshots cannot be used as native saves.
Scripts evaluate against a private draft. Unknown commands abort it. Dialogue commits only after the last page is acknowledged. A mid-dialogue save contains the last committed state, allowing the interaction to be replayed without partial rewards/flags. Automatic story scenes and callbacks are explicitly disabled in the exploration milestone.
Asset caches and the ROM reader remain in memory for lazy extraction. Neither ROMs nor extracted assets enter IndexedDB, the build or a backend. Vite blocks serving the root ROM and private reference checkout during development.
Introduce future modules with working features: `game/battle`, `game/party`, `game/inventory`, `game/audio`, then `features/link` and `features/walking`. Rules must not import UI/browser modules. Transports and step providers must not write directly into world state.
+60
View File
@@ -0,0 +1,60 @@
# Native port status and reference checks
## Reproducible reference
The private `.reference/pokecrystal` checkout is pinned to `7a7881d0d62e0ddbd82dcf10e7116807487ac651`. With RGBDS 1.0.3, its `crystal11` build is byte-identical to the supplied English US/Europe revision 1.1 ROM. `npm run check:reference` verifies the files, commit and every address in `src/game/rom/profile.ts` against generated symbols.
To recreate the private reference:
```sh
git clone https://github.com/pret/pokecrystal.git .reference/pokecrystal
git -C .reference/pokecrystal checkout 7a7881d0d62e0ddbd82dcf10e7116807487ac651
make -C .reference/pokecrystal crystal11 -j4
npm run check:reference
```
Install RGBDS 1.0.3 separately. No script downloads a game ROM. `CRYSTAL_ROM_PATH` optionally selects another local location; checks otherwise use `Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc` at the root.
The reference checkout, rebuilt ROM, extracted graphics, saves and gameplay screenshots must stay local and ignored. The shipping profile contains addresses and format constants, not game content.
## Routine map
These paths refer to the pinned [PRET source](https://github.com/pret/pokecrystal/tree/7a7881d0d62e0ddbd82dcf10e7116807487ac651).
| Native module | PRET reference | Evidence and limits |
| -------------------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `game/rom/lz.ts` | `home/decompress.asm` | All LZ command types have unit vectors; six tilesets match original ASM execution byte-for-byte. |
| `game/rom/assets.ts` maps/events | `data/maps/maps.asm`, `data/maps/attributes.asm`, `macros/scripts/maps.asm` | Six-map dimensions, events, destinations, navigation and return warps checked with the actual ROM. Connections are decoded but traversal is disabled. |
| Tiles/palettes | `home/map.asm`, `engine/gfx/color.asm`, `engine/tilesets/mapgroup_roofs.asm` | Graphics match rebuilt reference bytes. Day/night palettes and roofs supported; terrain animation/dynamic tiles and other special palettes pending. |
| Sprites/rendering | `data/sprites/sprites.asm`, `data/sprites/facings.asm` | Chris/Kris and standard NPC graphics/facings decoded. Dynamic Pokémon icons and room decorations pending. |
| `game/world/world.ts` | `engine/overworld/player_movement.asm`, `data/collision/collision_permissions.asm` | Walking, walls, visible objects and ordinary local warps. Not complete frame-by-frame parity; forced movement, ledges and surfing are refused. |
| Event interpreter | `macros/scripts/events.asm`, `engine/overworld/scripting.asm`, `engine/events/std_scripts.asm` | Bounded calls/jumps/conditions, flags, initial sprite variables and basic text interactions. Unknown commands abort; no assembly fallback. |
| Text interpreter | `macros/scripts/text.asm`, `constants/charmap.asm` | Literal/far text, line/paragraph controls and player substitution. Dynamic RAM/ASM text is unsupported. |
Supported event opcodes are explicit in the interpreter switch. Audio commands and delays consume their operands, but sound sequencing and presentation timing are pending; those opcodes do not imply complete audiovisual behavior.
## Actual emulator comparison
`tests/reference-parity.spec.ts` loads the user's ROM into the reference emulator and places a small original test trampoline in emulated WRAM. It calls the ROM's `FarDecompress` routine at `$0b40`, then sets a return sentinel. Every output byte is compared with the native decoder for six supported map tilesets. This executes the original routine rather than comparing two copies of our code.
That establishes decompression parity for those inputs. It does not prove complete gameplay, rendering, battles, audio, timing or physical-device parity. Extend controlled comparisons as each subsystem is translated.
## Validation
```sh
npm test
npm run test:e2e
npm run check:reference
npm run check:vendor
npm run build
npm run check:release
npm run build:reference
```
User-ROM tests skip when the file is absent, including public CI. Synthetic tests still run. Comparing with rebuilt graphics also needs the private PRET build. Do not report skipped private checks as CI passes.
Native production is `dist/`; the optional reference output is `dist-reference/`. Release checks reject emulator dependencies, binary game files and embedded binary assets in the native output. Source review is still required when adding assets or data.
## Next slice
Expand automatic scenes/callbacks and native party/inventory models to implement the opening through starter selection. Add the first battle loop with Route 29 and the Mystery Egg quest. Keep this story milestone separate from exploration saves until initialization and migration are defined.
+88
View File
@@ -0,0 +1,88 @@
# Full native port plan
## Direction
Translate Crystal's Game Boy assembly systems into a native TypeScript game. The browser runs the translated engine; the user's original ROM supplies data and assets. The emulator is a separate development reference for checking behavior, never a dependency of the native release.
PRET's [disassembly](https://github.com/pret/pokecrystal) is pinned at `7a7881d0d62e0ddbd82dcf10e7116807487ac651`. Its `crystal11` build with RGBDS 1.0.3 was verified byte-for-byte against the supplied English US/Europe 1.1 ROM. Required SHA-1: `f2f52230b536214ef7c9924f483392993e226cfb`. Other revisions require separate verified profiles.
The emulator-backed prototype is now at `/reference.html`. The default application is the native port. The previous plan to progressively modify an emulated ROM has been superseded.
## Implemented: first native exploration milestone
- Bounds-checked ROM access, banked pointers and native LZ decompression.
- On-demand extraction of six maps: New Bark Town, both floors of the player's home, Elm's lab, the neighbor's house and Elm's house.
- Metatiles, collision quadrants, palettes, roofs, sprites and facing tables read directly from the user's ROM.
- Native canvas rendering, camera scrolling, walking, solid-tile/NPC collision, doors and stairs within the supported area.
- A bounded interpreter for a subset of the game's own event bytecode, plus text decoding. This does not execute GB instructions.
- Original initial event flags, readable signs, bedroom radio, bookshelves and supported conversations. Unknown interactions abort without committing partial changes.
- Native DOM dialogue, name/avatar choice, touch/keyboard controls and pause/resume.
- Native JSON state inside the backup envelope, separate IndexedDB storage and a separate tab lock. No emulator snapshot or cartridge save migration is implied.
- Synthetic tests, local-ROM tests, browser checks, and comparisons against the original decompression routine running in the reference emulator.
- Native production output excludes the emulator and original game assets.
This is **exploration**, not a complete new game or story-compatible save. Opening scenes, automatic map callbacks/coordinate events, story progression, NPC movement, decorations, animated terrain, full collision behavior, connected routes, party/inventory, battles and audio remain unported. Online Link and real-world steps come later.
## 1. Complete the overworld foundation
Extend extraction to every map. Read the ROM's map index and location names rather than shipping a copied world database. Implement connected borders, seamless transitions, dynamic warps, elevators, ledges, ice, currents, forced movement, surfing, cycling, cut trees and boulders.
Port callbacks, decorations, tile changes, scheduled NPC visibility, NPC movement, trainer sight lines and scripted movement. Complete terrain animation, layering and special palettes. Record deviations from the original until a comparison test closes them.
Acceptance: representative outdoor/interior/cave/connected-map traces produce matching positions, collisions, transitions and event changes in native and reference engines. Do not enable an area merely because its image can be decoded.
## 2. Opening story and first battle slice
Introduce native player, party, Pokémon, inventory, money and quest models. Read species, moves, growth curves, evolutions, encounters, trainers and items through typed ROM decoders. Define serialization/migrations before persisting new systems.
Expand script calls, choices, scene state, inventory commands and native special handlers. Translate routines reached through assembly calls into explicit native services; never fall back to emulator execution in the shipping game.
Implement the introduction, naming/clock setup, meeting Mom, Elm's starter selection, Route 29, Cherrygrove and the Mystery Egg round trip. Keep exploration saves separate until migration is meaningful.
Build the required native battle engine: deterministic randomness, stats, move selection, accuracy/critical checks, damage, status, turn order, switching, fainting, experience, capture and rewards. Compare controlled seeds and states with the original, including integer rounding and overflow behavior.
Acceptance: finish the opening quest, including encounters and the rival battle, party/inventory changes and save/reload, without emulation or manually setting story flags.
## 3. Complete Crystal
Port remaining battle commands, move/item effects, AI, special battles, evolutions, breeding, happiness, eggs and PC storage. Gen II behavior is the baseline; list intentional changes separately.
Complete field moves, gyms, Rocket events, phone/Pokégear, shops, healing, puzzles, minigames, daily events, roaming Pokémon and postgame. Cover Johto, the League, Kanto and the final battle. Track unsupported script/special handlers and add fixtures before translating each one.
Port the music/sound sequencer and synthesize its channels through Web Audio, reading music, effects and wave data from the local ROM. No recordings or CPU emulator are needed in the release. Verify timing and audio unlocking on phones.
Acceptance: main-game/postgame playthroughs, representative optional content, battle regression vectors, long sessions, clock rollover, interruption and save recovery. No unknown command is silently skipped. The full port is not complete until these checks pass.
## 4. Modern mobile presentation and delivery
Build touch interfaces directly over native models: party, pack, battle commands, PC and Pokégear. Keep UI separate from gameplay calculations. Add scalable text, configurable controls and accessible focus/announcements.
Add PWA installation/offline caching of the app shell only. ROM/data retention remains a separate explicit local preference; otherwise reselect the ROM after reopening. Add safe application updates, schema migrations, storage persistence/quota handling and portable backups. Investigate original `.sav` import only with verified structure, checksums and RTC conversion.
Acceptance: installation, offline startup, portrait/landscape, touch/audio, background/resume and updates on physical iPhone/Safari and Android/Chrome. Browser automation does not establish physical-device compatibility.
## 5. Internet Link
Implement a game-level protocol over native state. This architecture does not need to tunnel serial timing or synchronize GB CPU cycles.
Separate `LinkTransport`, `TradeSession` and `BattleSession`. Start with local peers, then private invite rooms using signaling and WebRTC data channels or a relay. Negotiate compatible schema/game versions and exchange only required game data. Never transmit ROMs or extracted assets.
Trades need durable preparation, a transaction ID, agreed completion, idempotent recovery and pre-trade backups for both players. Do not retry ambiguous completion automatically. Battles need deterministic state, agreed randomness, turn commands, state checks and a disconnect policy. Define validation, room expiry and persistence before public service access.
Acceptance: cross-network trades/battles and induced duplicate messages, latency, disconnects and reconnects at every phase, without duplication or lost Pokémon.
## 6. Walking companion
Separate a platform `StepProvider`, local activity ledger and reward rules. Keep real-world steps distinct from the in-game tile counter. Deduplicate history, record credited intervals, bound invalid data and handle denied/revoked permission.
Foreground browser motion can help prototype the experience; reliable pocket/background tracking needs platform support. Evaluate a native wrapper with iOS Core Motion and Android Health Connect/step sensors, checking current permissions and hardware availability when implementing it.
Choose the initial reward explicitly, such as companion progress or an item goal. Apply it through native game commands at safe points with a durable, idempotent ledger transaction. Sensor callbacks must not write directly into game state. Keep activity data on device by default.
Acceptance: a real walk with a locked phone is recorded, credited exactly once after reopening, and safely reflected in the game. Verify interaction with trades and save restoration.
## Working method
For each subsystem: identify PRET routines/data, implement native code behind a narrow interface, compare controlled inputs against the reference, then integrate a playable scenario. Retain synthetic fixtures for public CI. Keep real-ROM state, graphics, audio, screenshots and reference builds in ignored local locations.
See [NATIVE_PORT.md](NATIVE_PORT.md) for the current routine map and verification commands.
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta name="theme-color" content="#102b2a" />
<meta
name="description"
content="A native web port of Pokémon Crystal, using assets from your own ROM. First exploration milestone."
/>
<title>Crystal · Native web port</title>
<link rel="icon" href="%BASE_URL%icon.svg" type="image/svg+xml" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+1573
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
{
"name": "crystal-web",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview --host 127.0.0.1",
"build:reference": "tsc --noEmit && vite build --mode reference",
"test": "vitest run",
"test:e2e": "playwright test",
"check:vendor": "node scripts/check-vendor.mjs",
"check:release": "node scripts/check-release.mjs",
"check:reference": "node scripts/check-reference.mjs"
},
"devDependencies": {
"@playwright/test": "1.63.0",
"@types/node": "26.6.2",
"fake-indexeddb": "6.2.5",
"prettier": "3.9.8",
"typescript": "7.0.2",
"vite": "8.3.0",
"vitest": "5.0.1"
}
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests",
testMatch: "**/*.spec.ts",
use: { baseURL: "http://127.0.0.1:5173", trace: "retain-on-failure" },
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{
name: "webkit",
use: { ...devices["iPhone 13"], defaultBrowserType: "webkit" },
},
],
webServer: {
command: "npm run dev -- --host 127.0.0.1",
url: "http://127.0.0.1:5173",
reuseExistingServer: !process.env.CI,
},
});
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#102b2a"/><path d="m32 9 18 16-18 30L14 25Z" fill="#bae5d0"/><path d="m32 9-5 16 5 30 5-30Z" fill="#fff6dc"/><path d="M14 25h36" stroke="#102b2a" stroke-width="2"/></svg>

After

Width:  |  Height:  |  Size: 274 B

+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,viewport-fit=cover"
/>
<title>Crystal · Reference emulator</title>
<meta name="robots" content="noindex" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/reference/main.ts"></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#102b2a"/><path d="m32 9 18 16-18 30L14 25Z" fill="#bae5d0"/><path d="m32 9-5 16 5 30 5-30Z" fill="#fff6dc"/><path d="M14 25h36" stroke="#102b2a" stroke-width="2"/></svg>

After

Width:  |  Height:  |  Size: 274 B

+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2016 Ben Smith
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+11
View File
@@ -0,0 +1,11 @@
# binjgb
Source: https://github.com/binji/binjgb
Pinned commit: `c60e138da5a795ebb55e56b11b7e90024e41112c`.
`binjgb.js` and `binjgb.wasm` are unmodified files from that commit's `docs/` directory. `LICENSE` is the upstream MIT license. No upstream demo ROMs, images, game data, or boot ROMs are included. The app uses the emulator's direct cartridge startup.
Run `npm run check:vendor` to verify SHA-256 checksums. Fetch replacements only from an explicitly pinned upstream commit, review the source and license, update the checksums, and run the synthetic-ROM integration tests. A core update also needs a save-state migration decision; never silently use old binary states with a different core ID.
The app's adapter was written against the upstream exported API and embedding example: https://github.com/binji/binjgb/blob/c60e138da5a795ebb55e56b11b7e90024e41112c/docs/simple.js
+2
View File
@@ -0,0 +1,2 @@
var Binjgb=(()=>{var _scriptName=globalThis.document?.currentScript?.src;return async function(moduleArg={}){var moduleRtn;var Module=moduleArg;var ENVIRONMENT_IS_WEB=true;var ENVIRONMENT_IS_WORKER=false;var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var EXITSTATUS;class EmscriptenEH{}class EmscriptenSjLj extends EmscriptenEH{}var readyPromiseResolve,readyPromiseReject;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;Module["HEAP8"]=HEAP8=new Int8Array(b);Module["HEAP16"]=HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);Module["HEAPU16"]=HEAPU16=new Uint16Array(b);Module["HEAP32"]=HEAP32=new Int32Array(b);Module["HEAPU32"]=HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);Module["HEAPF64"]=HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["e"]()}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what=`Aborted(${what})`;err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){return locateFile("binjgb.wasm")}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();return wasmExports}function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(inst,mod)=>{resolve(receiveInstance(inst,mod))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var HEAP16;var HEAP32;var HEAP64;var HEAP8;var HEAPF32;var HEAPF64;var HEAPU16;var HEAPU32;var HEAPU64;var HEAPU8;var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var noExitRuntime=true;var abortOnCannotGrowMemory=requestedSize=>{abort("OOM")};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;abortOnCannotGrowMemory(requestedSize)};var runtimeKeepaliveCounter=0;var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var _proc_exit=code=>{EXITSTATUS=code;if(!keepRuntimeAlive()){Module["onExit"]?.(code);ABORT=true}quit_(code,new ExitStatus(code))};var exitJS=(status,implicit)=>{EXITSTATUS=status;_proc_exit(status)};var _exit=exitJS;var printCharBuffers=[null,[],[]];var UTF8DecoderLine truncated
;return moduleRtn}})();if(typeof exports==="object"&&typeof module==="object"){module.exports=Binjgb;module.exports.default=Binjgb}else if(typeof define==="function"&&define["amd"])define([],()=>Binjgb);
Binary file not shown.
+63
View File
@@ -0,0 +1,63 @@
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
const root = ".reference/pokecrystal";
const expected = "f2f52230b536214ef7c9924f483392993e226cfb";
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
const supplied = readFileSync(romPath),
rebuilt = readFileSync(`${root}/pokecrystal11.gbc`);
if (
createHash("sha1").update(supplied).digest("hex") !== expected ||
!supplied.equals(rebuilt)
)
throw new Error(
"The supplied ROM and reference rebuild must exactly match revision 1.1.",
);
const revision = execFileSync("git", ["-C", root, "rev-parse", "HEAD"], {
encoding: "utf8",
}).trim();
if (revision !== "7a7881d0d62e0ddbd82dcf10e7116807487ac651")
throw new Error("Unexpected PRET revision.");
const symbols = new Map(
readFileSync(`${root}/pokecrystal11.sym`, "utf8")
.split("\n")
.flatMap((line) => {
const match = /^([0-9a-f]{2}):([0-9a-f]{4}) (\S+)$/.exec(line);
if (!match) return [];
const bank = parseInt(match[1], 16),
address = parseInt(match[2], 16);
return [
[
match[3],
address < 0x4000 ? address : bank * 0x4000 + address - 0x4000,
],
];
}),
);
const names = {
mapGroups: "MapGroupPointers",
tilesets: "Tilesets",
collisions: "CollisionPermissionTable",
environmentColors: "EnvironmentColorsPointers",
backgroundPalettes: "TilesetBGPalette",
objectPalettes: "MapObjectPals",
roofPalettes: "RoofPals",
roofTypes: "MapGroupRoofs",
roofs: "Roofs",
housePalettes: "HousePalette",
sprites: "OverworldSprites",
facings: "Facings",
standardScripts: "StdScripts",
};
const profile = readFileSync("src/game/rom/profile.ts", "utf8");
for (const [key, symbol] of Object.entries(names)) {
const match = new RegExp(`${key}:\\s*(0x[0-9a-f]+)`, "i").exec(profile);
if (!match || Number(match[1]) !== symbols.get(symbol))
throw new Error(`Profile address mismatch: ${key} / ${symbol}`);
}
console.log(
"User ROM, pinned PRET rebuild and all native profile addresses match.",
);
+30
View File
@@ -0,0 +1,30 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
let files = 0,
size = 0;
function inspect(directory) {
for (const name of readdirSync(directory)) {
const path = join(directory, name);
if (statSync(path).isDirectory()) {
inspect(path);
continue;
}
if (!/\.(html|js|css|svg)$/.test(name))
throw new Error(`Unexpected production asset: ${path}`);
if (/reference|vendor|binjgb/i.test(path))
throw new Error(`Reference tool in production: ${path}`);
const bytes = readFileSync(path),
text = bytes.toString("utf8");
if (/Binjgb|_emulator_|binjgb\.wasm|\.reference\/pokecrystal/.test(text))
throw new Error(`Reference dependency in production: ${path}`);
if (/data:(image|audio|application\/octet-stream)[^,]*;base64,/i.test(text))
throw new Error(`Embedded binary asset in production: ${path}`);
files++;
size += bytes.length;
}
}
inspect("dist");
console.log(
`Native release verified: ${files} HTML/JS/CSS/SVG files, ${size} bytes; no emulator, ROM, extracted binary assets or reference checkout.`,
);
+21
View File
@@ -0,0 +1,21 @@
import { createHash } from "node:crypto";
import { readFileSync, readdirSync } from "node:fs";
const expected = {
LICENSE: "5a7b61a51a5efb789721b3480b502f324094911aeca46937956c561b52638009",
"binjgb.js":
"a68e5854b5d68339a34c0aa0b6dbeacd615ed316657ee17b63dafa5e00561b09",
"binjgb.wasm":
"52c165069441a09180b4bd63464318aa7b861b1d36e0d05e8047b48619fd2f0b",
};
for (const [name, hash] of Object.entries(expected)) {
const actual = createHash("sha256")
.update(readFileSync(`reference/public/vendor/binjgb/${name}`))
.digest("hex");
if (actual !== hash) throw new Error(`Vendor integrity mismatch: ${name}`);
}
for (const path of readdirSync("public", { recursive: true })) {
if (/\.(gbc?|rom|sav|zip)$/i.test(path))
throw new Error(`Game data must not be shipped: ${path}`);
}
console.log("Pinned emulator integrity and public game-file checks passed.");
+13
View File
@@ -0,0 +1,13 @@
import type { Button } from "../input/types";
/** Application lifecycle contract, independent of emulation and hardware. */
export interface GameRuntime {
readonly id: string;
load(rom: Uint8Array, canvas: HTMLCanvasElement): Promise<void>;
play(): void;
pause(): void;
setButton(button: Button, pressed: boolean): void;
captureState(): Uint8Array;
restoreState(state: Uint8Array): void;
dispose(): void;
}
+90
View File
@@ -0,0 +1,90 @@
import type { GameRuntime } from "./runtime";
import type { ValidatedRom } from "../rom/validate";
import {
decodeSave,
type SaveRecord,
type SaveRepository,
} from "../storage/saves";
export type SessionState = "empty" | "loading" | "paused" | "playing" | "error";
export class GameSession {
state: SessionState = "empty";
private rom?: ValidatedRom;
// Queue snapshots in capture order; a slow write cannot overwrite newer progress.
private writes: Promise<void> = Promise.resolve();
constructor(
readonly runtime: GameRuntime,
private saves: SaveRepository,
) {}
async load(rom: ValidatedRom, canvas: HTMLCanvasElement) {
this.state = "loading";
this.rom = undefined;
try {
const saved = await this.saves.read(rom.sha1);
if (saved && (saved.version !== 1 || saved.coreId !== this.runtime.id)) {
throw new Error(
"The stored session needs migration before this player can open it. Your save has been kept.",
);
}
await this.runtime.load(rom.bytes, canvas);
if (saved) this.runtime.restoreState(saved.state);
// Never retain ROM bytes in storage or the session after loading.
this.rom = { ...rom, bytes: new Uint8Array() };
this.state = "paused";
return Boolean(saved);
} catch (error) {
this.runtime.dispose();
this.state = "error";
throw error;
}
}
play() {
if (this.state !== "paused") return;
this.runtime.play();
this.state = "playing";
}
pause() {
if (this.state !== "playing") return;
this.runtime.pause();
this.state = "paused";
}
snapshot(): SaveRecord {
if (!this.rom || !["paused", "playing"].includes(this.state))
throw new Error("Load a ROM before saving.");
return {
version: 1,
romHash: this.rom.sha1,
coreId: this.runtime.id,
savedAt: new Date().toISOString(),
state: this.runtime.captureState(),
};
}
save() {
const record = this.snapshot();
const operation = this.writes
.catch(() => {})
.then(() => this.saves.write(record));
this.writes = operation;
return operation.then(() => record.savedAt);
}
async importSave(text: string) {
if (!this.rom) throw new Error("Load your ROM before importing a backup.");
const record = decodeSave(text, this.rom.sha1, this.runtime.id);
this.pause();
const previous = this.runtime.captureState();
try {
this.runtime.restoreState(record.state);
} catch (error) {
this.runtime.restoreState(previous);
throw error;
}
try {
await this.save();
} catch (error) {
this.runtime.restoreState(previous);
throw error;
}
}
}
+332
View File
@@ -0,0 +1,332 @@
import { BUTTONS, type Button, type Emulator } from "./types";
type NativeCall = (...args: number[]) => number;
interface BinjgbModule {
HEAPU8: Uint8Array;
[name: `_${string}`]: NativeCall;
}
declare global {
interface Window {
Binjgb?: (options: {
locateFile: (path: string) => string;
}) => Promise<BinjgbModule>;
}
}
export const CORE_ID = "binjgb-c60e138da5a795ebb55e56b11b7e90024e41112c";
let scriptReady: Promise<void> | undefined;
async function createModule() {
const base = `${import.meta.env.BASE_URL}${import.meta.env.DEV && import.meta.env.MODE !== "reference" ? "reference/public/" : ""}vendor/binjgb/`;
if (!window.Binjgb) {
scriptReady ??= new Promise<void>((resolve, reject) => {
const script = document.createElement("script");
script.src = `${base}binjgb.js`;
script.onload = () => resolve();
script.onerror = () => {
script.remove();
scriptReady = undefined;
reject(new Error("Could not load the emulator. Reload and try again."));
};
document.head.append(script);
});
await scriptReady;
}
if (!window.Binjgb) throw new Error("Emulator initialization failed.");
return window.Binjgb({ locateFile: (path) => `${base}${path}` });
}
/** Vendor-specific exports, timing, video and audio stay behind Emulator. */
export class BinjgbEmulator implements Emulator {
readonly id = CORE_ID;
private module?: BinjgbModule;
private handle = 0;
private romPointer = 0;
private joypadPointer = 0;
private canvas?: CanvasRenderingContext2D;
private frame?: ImageData;
private audio?: AudioContext;
private gain?: GainNode;
private sources = new Set<AudioBufferSourceNode>();
private nextAudioTime = 0;
private raf = 0;
private lastTime = 0;
private muted = true;
constructor(private onError: (error: unknown) => void = () => {}) {}
async load(rom: Uint8Array, canvas: HTMLCanvasElement) {
this.dispose();
try {
this.module = await createModule();
// Audio is optional. Failure must not block a silent game.
try {
this.audio = new AudioContext();
this.gain = this.audio.createGain();
this.gain.gain.value = this.muted ? 0 : 0.4;
this.gain.connect(this.audio.destination);
} catch {
this.audio = undefined;
}
this.romPointer = this.module._malloc(rom.length);
if (!this.romPointer) throw new Error("Could not allocate ROM memory.");
this.module.HEAPU8.set(rom, this.romPointer);
this.handle = this.module._emulator_new_simple(
this.romPointer,
rom.length,
this.audio?.sampleRate ?? 44100,
2048,
2,
);
if (!this.handle)
throw new Error("The emulator could not open this ROM.");
this.joypadPointer = this.module._joypad_new();
if (!this.joypadPointer)
throw new Error("Could not initialize controls.");
this.module._emulator_set_default_joypad_callback(
this.handle,
this.joypadPointer,
);
canvas.width = 160;
canvas.height = 144;
this.canvas = canvas.getContext("2d", { alpha: false }) ?? undefined;
if (!this.canvas) throw new Error("Canvas rendering is unavailable.");
this.frame = this.canvas.createImageData(160, 144);
} catch (error) {
this.dispose();
throw error;
}
}
play() {
if (!this.handle || this.raf) return;
this.lastTime = 0;
this.raf = requestAnimationFrame(this.tick);
this.unlockAudio();
}
pause() {
cancelAnimationFrame(this.raf);
this.raf = 0;
this.lastTime = 0;
for (const button of BUTTONS) this.setButton(button, false);
for (const source of this.sources) source.stop();
this.sources.clear();
this.nextAudioTime = 0;
}
private tick = (now: number) => {
try {
const module = this.module!;
const elapsed = this.lastTime
? Math.min((now - this.lastTime) / 1000, 0.05)
: 1 / 60;
this.lastTime = now;
const target =
module._emulator_get_ticks_f64(this.handle) + elapsed * 4194304;
// Bound iterations so a broken core cannot freeze the page forever.
let complete = false;
for (let iteration = 0; iteration < 100; iteration++) {
const event = module._emulator_run_until_f64(this.handle, target);
if (event & 1) {
const pointer = module._get_frame_buffer_ptr(this.handle);
this.frame!.data.set(
module.HEAPU8.subarray(pointer, pointer + 160 * 144 * 4),
);
this.canvas!.putImageData(this.frame!, 0, 0);
}
if (event & 2) this.queueAudio();
if (event & 4) {
complete = true;
break;
}
}
if (!complete)
throw new Error(
"Emulation stopped unexpectedly. Reload your ROM to recover.",
);
this.raf = requestAnimationFrame(this.tick);
} catch (error) {
this.pause();
this.onError(error);
}
};
private queueAudio() {
const audio = this.audio;
if (!audio || audio.state !== "running" || this.muted || !this.gain) return;
const frames = 2048;
const pointer = this.module!._get_audio_buffer_ptr(this.handle);
const samples = this.module!.HEAPU8.subarray(pointer, pointer + frames * 2);
if (this.nextAudioTime > audio.currentTime + 0.25) return;
this.nextAudioTime = Math.max(
this.nextAudioTime,
audio.currentTime + 0.025,
);
const buffer = audio.createBuffer(2, frames, audio.sampleRate);
for (let channel = 0; channel < 2; channel++) {
const data = buffer.getChannelData(channel);
for (let i = 0; i < frames; i++) data[i] = samples[i * 2 + channel] / 255;
}
const source = audio.createBufferSource();
source.buffer = buffer;
source.connect(this.gain);
source.onended = () => {
this.sources.delete(source);
source.disconnect();
};
this.sources.add(source);
source.start(this.nextAudioTime);
this.nextAudioTime += frames / audio.sampleRate;
}
setButton(button: Button, pressed: boolean) {
if (!this.handle) return;
const nativeName =
button === "a" || button === "b" ? button.toUpperCase() : button;
this.module![`_set_joyp_${nativeName}`](this.handle, Number(pressed));
}
setMuted(muted: boolean) {
this.muted = muted;
if (this.gain) this.gain.gain.value = muted ? 0 : 0.4;
if (!muted) this.unlockAudio();
}
unlockAudio() {
if (this.audio?.state === "suspended")
void this.audio.resume().catch(() => {});
}
/** Development-only observation API. This adapter is not in the native
* production bundle. Used to compare translated routines with original ASM. */
readMemory(address: number): number {
if (
!this.handle ||
!Number.isInteger(address) ||
address < 0 ||
address > 0xffff
)
throw new Error("Invalid reference memory read.");
return this.module!._emulator_read_mem(this.handle, address);
}
writeMemory(address: number, value: number) {
if (
!this.handle ||
!Number.isInteger(address) ||
address < 0 ||
address > 0xffff ||
!Number.isInteger(value) ||
value < 0 ||
value > 255
)
throw new Error("Invalid reference memory write.");
this.module!._emulator_write_mem(this.handle, address, value);
}
setProgramCounter(address: number) {
if (
!this.handle ||
this.raf ||
!Number.isInteger(address) ||
address < 0 ||
address > 0xffff
)
throw new Error(
"Pause the reference before changing its program counter.",
);
this.module!._emulator_set_PC(this.handle, address);
}
advanceFrames(frames: number) {
if (
!this.handle ||
this.raf ||
!Number.isInteger(frames) ||
frames < 1 ||
frames > 3600
)
throw new Error("Pause the reference before bounded frame stepping.");
const module = this.module!;
for (let frame = 0; frame < frames; frame++) {
const target = module._emulator_get_ticks_f64(this.handle) + 70224;
let complete = false;
for (let events = 0; events < 100; events++) {
const event = module._emulator_run_until_f64(this.handle, target);
if (event & 1) {
const pointer = module._get_frame_buffer_ptr(this.handle);
this.frame!.data.set(
module.HEAPU8.subarray(pointer, pointer + 160 * 144 * 4),
);
}
if (event & 4) {
complete = true;
break;
}
}
if (!complete) throw new Error("Reference frame budget exceeded.");
}
this.canvas!.putImageData(this.frame!, 0, 0);
}
private fileData(
kind: "state" | "ext_ram",
action: (pointer: number, bytes: Uint8Array) => void,
) {
if (!this.handle || !this.module) throw new Error("Load a ROM first.");
const module = this.module;
const pointer = module[`_${kind}_file_data_new`](this.handle);
if (!pointer) throw new Error("Could not allocate save memory.");
try {
const start = module._get_file_data_ptr(pointer);
const length = module._get_file_data_size(pointer);
action(pointer, module.HEAPU8.subarray(start, start + length));
} finally {
module._file_data_delete(pointer);
}
}
captureState() {
let state!: Uint8Array;
this.fileData("state", (pointer, bytes) => {
if (this.module!._emulator_write_state(this.handle, pointer) !== 0)
throw new Error("Could not capture the session.");
state = bytes.slice();
});
return state;
}
restoreState(state: Uint8Array) {
const current = this.captureState();
// Pinned EmulatorState: header at 0..3, cartridge-info index at 8.
// Refuse an incompatible header/index before passing untrusted bytes to C.
if (
state.length !== current.length ||
state[8] !== current[8] ||
!state.subarray(0, 4).every((byte, index) => byte === current[index])
) {
throw new Error("This backup has an invalid state header or size.");
}
this.fileData("state", (pointer, bytes) => {
if (state.length !== bytes.length)
throw new Error("This backup has an invalid state size.");
bytes.set(state);
if (this.module!._emulator_read_state(this.handle, pointer) !== 0)
throw new Error("This backup contains an invalid state.");
});
for (const button of BUTTONS) this.setButton(button, false);
}
exportBattery() {
let battery!: Uint8Array;
this.fileData("ext_ram", (pointer, bytes) => {
if (this.module!._emulator_write_ext_ram(this.handle, pointer) !== 0)
throw new Error("Could not export the in-game save.");
battery = bytes.slice();
});
return battery;
}
dispose() {
this.pause();
if (this.handle) this.module!._emulator_delete(this.handle);
if (this.joypadPointer) this.module!._joypad_delete(this.joypadPointer);
if (this.romPointer) this.module!._free(this.romPointer);
this.handle = 0;
this.joypadPointer = 0;
this.romPointer = 0;
void this.audio?.close().catch(() => {});
this.audio = undefined;
this.gain = undefined;
this.module = undefined;
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { Button } from "../input/types";
export { BUTTONS, type Button } from "../input/types";
/** The UI never accesses WASM memory or a vendor API. */
export interface Emulator {
readonly id: string;
load(rom: Uint8Array, canvas: HTMLCanvasElement): Promise<void>;
play(): void;
pause(): void;
setButton(button: Button, pressed: boolean): void;
setMuted(muted: boolean): void;
unlockAudio(): void;
captureState(): Uint8Array;
restoreState(state: Uint8Array): void;
exportBattery(): Uint8Array;
dispose(): void;
}
+36
View File
@@ -0,0 +1,36 @@
import type { Palette } from "../world/types";
/** Decode Game Boy planar pixels directly to RGBA; no display hardware emulation. */
export function drawTile(
target: Uint8ClampedArray,
width: number,
height: number,
graphics: Uint8Array,
tile: number,
x: number,
y: number,
palette: Palette,
options: { transparent?: boolean; flipX?: boolean; flipY?: boolean } = {},
) {
const offset = tile * 16;
if (offset < 0 || offset + 16 > graphics.length) return;
for (let row = 0; row < 8; row++) {
const sourceRow = options.flipY ? 7 - row : row;
const lo = graphics[offset + sourceRow * 2],
hi = graphics[offset + sourceRow * 2 + 1];
for (let col = 0; col < 8; col++) {
const bit = options.flipX ? col : 7 - col;
const index = ((lo >> bit) & 1) | (((hi >> bit) & 1) << 1);
if (options.transparent && index === 0) continue;
const px = x + col,
py = y + row;
if (px < 0 || py < 0 || px >= width || py >= height) continue;
const destination = (py * width + px) * 4,
color = palette[index];
target[destination] = color[0];
target[destination + 1] = color[1];
target[destination + 2] = color[2];
target[destination + 3] = 255;
}
}
}
+137
View File
@@ -0,0 +1,137 @@
import { CrystalAssets } from "../rom/assets";
import { GameWorld } from "../world/world";
import type { Direction } from "../world/types";
import { drawTile } from "./pixels";
export class WorldRenderer {
readonly width = 160;
readonly height = 144;
private context: CanvasRenderingContext2D;
private image: ImageData;
constructor(
private assets: CrystalAssets,
canvas: HTMLCanvasElement,
) {
canvas.width = this.width;
canvas.height = this.height;
const context = canvas.getContext("2d", { alpha: false });
if (!context) throw new Error("Canvas rendering is unavailable.");
this.context = context;
this.image = context.createImageData(this.width, this.height);
}
render(
world: GameWorld,
playerX = world.x * 16,
playerY = world.y * 16,
frame = 0,
) {
const map = world.map,
tileset = this.assets.tileset(map),
palettes = this.assets.palettes(map, world.timeOfDay);
// Keep the camera player-centered as the original engine does. Out-of-map
// pixels use the map's own border block, including small room padding.
const cameraX = Math.round(playerX - 72),
cameraY = Math.round(playerY - 64);
this.image.data.fill(255);
for (
let tileY = Math.floor(cameraY / 8);
tileY < Math.ceil((cameraY + this.height) / 8);
tileY++
) {
for (
let tileX = Math.floor(cameraX / 8);
tileX < Math.ceil((cameraX + this.width) / 8);
tileX++
) {
const block = this.assets.block(
map,
Math.floor(tileX / 2),
Math.floor(tileY / 2),
);
const localX = ((tileX % 4) + 4) % 4,
localY = ((tileY % 4) + 4) % 4;
const encoded = tileset.metatiles[block * 16 + localY * 4 + localX];
const packed = tileset.paletteMap[encoded >> 1],
attribute = encoded & 1 ? packed >> 4 : packed & 15;
const tile = (encoded & 0x7f) + (attribute & 8 ? 0x60 : 0);
drawTile(
this.image.data,
this.width,
this.height,
tileset.graphics,
tile,
tileX * 8 - cameraX,
tileY * 8 - cameraY,
palettes[attribute & 7],
);
}
}
const actors = world
.objects()
.map((object) => ({
x: object.x * 16,
y: object.y * 16,
sprite: object.sprite,
direction: world.objectDirection(object),
palette: object.palette ? object.palette - 1 : undefined,
frame: 0,
}));
actors.push({
x: playerX,
y: playerY,
sprite: world.avatar === "kris" ? 0x60 : 1,
direction: world.direction,
palette: undefined,
frame,
});
actors.sort((a, b) => a.y - b.y);
for (const actor of actors)
this.actor(
actor.sprite,
Math.round(actor.x - cameraX),
Math.round(actor.y - cameraY - 4),
actor.direction,
actor.frame,
world.timeOfDay,
actor.palette,
);
this.context.putImageData(this.image, 0, 0);
}
private actor(
id: number,
x: number,
y: number,
direction: Direction,
frame: number,
time: number,
paletteIndex?: number,
) {
const sprite = this.assets.sprite(id);
if (!sprite) return;
const palette = this.assets.objectPalette(
paletteIndex ?? sprite.palette,
time,
);
for (const piece of this.assets.facing(
direction,
sprite.walking ? frame : 0,
)) {
const tile = piece.tile >= 0x80 ? piece.tile - 0x80 + 12 : piece.tile;
drawTile(
this.image.data,
this.width,
this.height,
sprite.graphics,
tile,
x + piece.x,
y + piece.y,
palette,
{
transparent: true,
flipX: Boolean(piece.attributes & 0x20),
flipY: Boolean(piece.attributes & 0x40),
},
);
}
}
}
+245
View File
@@ -0,0 +1,245 @@
import { RomReader } from "./reader";
import { decompress } from "./lz";
import { NATIVE_MAPS, SYMBOLS } from "./profile";
import type {
Direction,
GameMap,
Tileset,
Palette,
Sprite,
FacingTile,
} from "../world/types";
/** The only layer that knows where Crystal stores its data. Everything is
* decoded from the supplied ROM, on demand, and cached only in memory. */
export class CrystalAssets {
readonly reader: RomReader;
private maps = new Map<string, GameMap>();
private tilesets = new Map<string, Tileset>();
private sprites = new Map<number, Sprite>();
constructor(bytes: Uint8Array) {
this.reader = new RomReader(bytes);
}
map(id: string): GameMap {
const cached = this.maps.get(id);
if (cached) return cached;
if (!Object.hasOwn(NATIVE_MAPS, id))
throw new Error("This area has not been ported yet.");
const [group, number] = id.split(":").map(Number);
const r = this.reader;
const header =
r.near(SYMBOLS.mapGroups + (group - 1) * 2, r.bank(SYMBOLS.mapGroups)) +
(number - 1) * 9;
const attributes = r.near(header + 3, r.byte(header));
const blockWidth = r.byte(attributes + 2),
blockHeight = r.byte(attributes + 1);
if (!blockWidth || !blockHeight || blockWidth > 64 || blockHeight > 64)
throw new Error("Invalid map dimensions.");
const scriptBank = r.byte(attributes + 6);
let p = r.near(attributes + 9, scriptBank) + 2;
const map: GameMap = {
id,
group,
number,
name: NATIVE_MAPS[id],
width: blockWidth * 2,
height: blockHeight * 2,
border: r.byte(attributes),
tileset: r.byte(header + 1),
environment: r.byte(header + 2),
paletteMode: r.byte(header + 7) & 15,
blocks: r.slice(r.far(attributes + 3), blockWidth * blockHeight),
warps: [],
backgroundEvents: [],
objects: [],
connections: [],
sceneScripts: [],
callbacks: [],
coordinateEvents: [],
};
const warpCount = r.byte(p++);
for (let i = 0; i < warpCount; i++, p += 5) {
map.warps.push({
y: r.byte(p),
x: r.byte(p + 1),
targetWarp: r.byte(p + 2),
targetMap: `${r.byte(p + 3)}:${r.byte(p + 4)}`,
});
}
const coordinateCount = r.byte(p++);
for (let i = 0; i < coordinateCount; i++, p += 8) {
map.coordinateEvents.push({
scene: r.byte(p),
y: r.byte(p + 1),
x: r.byte(p + 2),
script: r.near(p + 4, scriptBank),
});
}
const backgroundCount = r.byte(p++);
for (let i = 0; i < backgroundCount; i++, p += 5) {
map.backgroundEvents.push({
y: r.byte(p),
x: r.byte(p + 1),
kind: r.byte(p + 2),
script: r.near(p + 3, scriptBank),
});
}
const objectCount = r.byte(p++);
for (let i = 0; i < objectCount; i++, p += 13) {
map.objects.push({
id: i + 2,
sprite: r.byte(p),
y: r.byte(p + 1) - 4,
x: r.byte(p + 2) - 4,
movement: r.byte(p + 3),
hour1: r.byte(p + 5),
hour2: r.byte(p + 6),
palette: r.byte(p + 7) >> 4,
kind: r.byte(p + 7) & 15,
script: r.near(p + 9, scriptBank),
eventFlag: r.word(p + 11),
});
}
p = r.near(attributes + 7, scriptBank);
const sceneCount = r.byte(p++);
for (let i = 0; i < sceneCount; i++, p += 4)
map.sceneScripts.push(r.near(p, scriptBank));
const callbackCount = r.byte(p++);
for (let i = 0; i < callbackCount; i++, p += 3)
map.callbacks.push({
kind: r.byte(p),
script: r.near(p + 1, scriptBank),
});
p = attributes + 12;
for (const [direction, bit] of [
["up", 8],
["down", 4],
["left", 2],
["right", 1],
] as const) {
if (!(r.byte(attributes + 11) & bit)) continue;
const signed = (v: number) => (v > 127 ? v - 256 : v);
map.connections.push({
direction,
targetMap: `${r.byte(p)}:${r.byte(p + 1)}`,
y: signed(r.byte(p + 8)),
x: signed(r.byte(p + 9)),
});
p += 12;
}
this.maps.set(id, map);
return map;
}
tileset(map: GameMap): Tileset {
const key = `${map.tileset}:${map.group}`;
const cached = this.tilesets.get(key);
if (cached) return cached;
const r = this.reader,
base = SYMBOLS.tilesets + map.tileset * 15;
const graphics = decompress(r, r.far(base), 0x4000);
const meta = r.far(base + 3),
collision = r.far(base + 6);
// Each tileset contains 128 metatiles (4x4 8px tiles), each with four
// quadrant permissions for the 16px movement grid.
const result: Tileset = {
graphics,
metatiles: r.slice(meta, 128 * 16),
collision: r.slice(collision, 128 * 4),
paletteMap: r.slice(r.near(base + 13, r.bank(SYMBOLS.tilesets)), 128),
};
if ([1, 2, 4].includes(map.tileset)) {
const roof = r.byte(SYMBOLS.roofTypes + map.group);
if (roof !== 255)
result.graphics.set(
r.slice(SYMBOLS.roofs + roof * 9 * 16, 9 * 16),
0x0a * 16,
);
}
this.tilesets.set(key, result);
return result;
}
block(map: GameMap, x: number, y: number): number {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return map.border;
return map.blocks[Math.floor(y / 2) * (map.width / 2) + Math.floor(x / 2)];
}
collision(map: GameMap, x: number, y: number): number {
if (x < 0 || y < 0 || x >= map.width || y >= map.height) return 7;
const block = this.block(map, x, y);
if (block >= 128) throw new Error("Map contains an invalid block.");
return this.tileset(map).collision[block * 4 + (y % 2) * 2 + (x % 2)];
}
permission(collision: number): number {
return this.reader.byte(SYMBOLS.collisions + collision) & 15;
}
palette(offset: number): Palette {
return Array.from({ length: 4 }, (_, i) => {
const color = this.reader.word(offset + i * 2);
return [
Math.round(((color & 31) * 255) / 31),
Math.round((((color >> 5) & 31) * 255) / 31),
Math.round((((color >> 10) & 31) * 255) / 31),
];
});
}
palettes(map: GameMap, time: number): Palette[] {
const r = this.reader;
const group =
r.near(
SYMBOLS.environmentColors + map.environment * 2,
r.bank(SYMBOLS.environmentColors),
) +
time * 8;
const palettes = Array.from({ length: 8 }, (_, i) =>
this.palette(
map.tileset === 5
? SYMBOLS.housePalettes + i * 8
: SYMBOLS.backgroundPalettes + r.byte(group + i) * 8,
),
);
if (map.environment === 1 || map.environment === 2) {
const roof = SYMBOLS.roofPalettes + map.group * 8 + (time >= 2 ? 4 : 0);
const colors = this.palette(roof);
palettes[6] = [palettes[6][0], colors[0], colors[1], palettes[6][3]];
}
return palettes;
}
objectPalette(index: number, time: number): Palette {
return this.palette(SYMBOLS.objectPalettes + time * 64 + (index & 7) * 8);
}
sprite(id: number): Sprite | undefined {
// Variable decorations and Pokémon icons need separate decoders.
if (id < 1 || id >= 0xa0) return undefined;
const cached = this.sprites.get(id);
if (cached) return cached;
const r = this.reader,
p = SYMBOLS.sprites + (id - 1) * 6;
const length = r.byte(p + 2),
walking = r.byte(p + 4) === 1;
const sprite = {
graphics: r.slice(r.near(p, r.byte(p + 3)), length * (walking ? 2 : 1)),
palette: r.byte(p + 5),
walking,
};
this.sprites.set(id, sprite);
return sprite;
}
facing(direction: Direction, frame: number): FacingTile[] {
const facing =
["down", "up", "left", "right"].indexOf(direction) * 4 + (frame % 4);
const r = this.reader,
pointer = r.near(SYMBOLS.facings + facing * 2, r.bank(SYMBOLS.facings));
return Array.from({ length: r.byte(pointer) }, (_, i) => {
const p = pointer + 1 + i * 4;
return {
y: r.byte(p),
x: r.byte(p + 1),
attributes: r.byte(p + 2),
tile: r.byte(p + 3),
};
});
}
}
+57
View File
@@ -0,0 +1,57 @@
import { RomReader } from "./reader";
/** Native translation of Crystal's LZ data format (home/decompress.asm).
* Copy commands may overlap their own output. Addresses are relative to the
* decompressed stream, never arbitrary memory. */
export function decompress(
reader: RomReader,
offset: number,
limit = 0x10000,
): Uint8Array {
const output: number[] = [];
let cursor = offset;
const read = () => reader.byte(cursor++);
for (let commandCount = 0; commandCount < limit; commandCount++) {
const header = read();
if (header === 0xff) return Uint8Array.from(output);
let command = header >> 5;
let length = (header & 31) + 1;
if (command === 7) {
command = (header >> 2) & 7;
length = (((header & 3) << 8) | read()) + 1;
}
if (command === 7 || output.length + length > limit)
throw new Error("Invalid or oversized compressed graphic.");
if (command === 0) {
for (let i = 0; i < length; i++) output.push(read());
} else if (command === 1) {
const value = read();
for (let i = 0; i < length; i++) output.push(value);
} else if (command === 2) {
const a = read(),
b = read();
for (let i = 0; i < length; i++) output.push(i % 2 ? b : a);
} else if (command === 3) {
for (let i = 0; i < length; i++) output.push(0);
} else {
const first = read();
let source =
first & 0x80
? output.length - (first & 0x7f) - 1
: (first << 8) | read();
for (let i = 0; i < length; i++) {
if (source < 0 || source >= output.length)
throw new Error("Invalid compressed graphic back-reference.");
let value = output[source];
if (command === 5) {
value = ((value & 0x55) << 1) | ((value >> 1) & 0x55);
value = ((value & 0x33) << 2) | ((value >> 2) & 0x33);
value = ((value & 0x0f) << 4) | (value >> 4);
}
output.push(value);
source += command === 6 ? -1 : 1;
}
}
}
throw new Error("Compressed graphic has no terminator.");
}
+30
View File
@@ -0,0 +1,30 @@
/** Addresses only, from PRET 7a7881d0d62e0ddbd82dcf10e7116807487ac651.
* RGBDS 1.0.3 crystal11 build was byte-identical to the user-supplied ROM.
* No original graphics, maps, dialogue, music or ROM instructions live here.
* See docs/NATIVE_PORT.md for source references and verification procedure. */
export const SYMBOLS = {
mapGroups: 0x94000,
tilesets: 0x4d596,
collisions: 0x4ce1f,
environmentColors: 0xb279,
backgroundPalettes: 0xb319,
objectPalettes: 0xb469,
roofPalettes: 0xb569,
roofTypes: 0x1c021,
roofs: 0x1c03c,
housePalettes: 0x495ee,
sprites: 0x14736,
facings: 0x4049,
standardScripts: 0xbc000,
} as const;
/** The currently ported area. Story callbacks are not executed yet. */
export const NATIVE_MAPS: Record<string, string> = {
"24:4": "New Bark Town",
"24:5": "Elm’s lab",
"24:6": "Your home",
"24:7": "Your room",
"24:8": "Neighbor’s house",
"24:9": "Elm’s house",
};
export const INITIAL_POSITION = { map: "24:7", x: 3, y: 3 } as const;
+54
View File
@@ -0,0 +1,54 @@
/** Bounds-checked access to the user's cartridge. No CPU or hardware state. */
export class RomReader {
constructor(readonly bytes: Uint8Array) {}
byte(offset: number): number {
this.check(offset, 1);
return this.bytes[offset];
}
word(offset: number): number {
return this.byte(offset) | (this.byte(offset + 1) << 8);
}
slice(offset: number, length: number): Uint8Array {
this.check(offset, length);
return this.bytes.slice(offset, offset + length);
}
address(bank: number, address: number): number {
if (
!Number.isInteger(bank) ||
bank < 0 ||
bank >= this.bytes.length / 0x4000 ||
!Number.isInteger(address) ||
address < 0 ||
address >= 0x8000
) {
throw new Error("Invalid ROM bank or address.");
}
const offset =
address < 0x4000 ? address : bank * 0x4000 + address - 0x4000;
this.check(offset, 1);
return offset;
}
near(pointer: number, bank: number): number {
return this.address(bank, this.word(pointer));
}
far(pointer: number): number {
return this.near(pointer + 1, this.byte(pointer));
}
bank(offset: number): number {
this.check(offset, 1);
return Math.floor(offset / 0x4000);
}
private check(offset: number, length: number) {
if (
!Number.isInteger(offset) ||
!Number.isInteger(length) ||
offset < 0 ||
length < 0 ||
offset + length > this.bytes.length
) {
throw new Error(
"Cartridge data is truncated or contains an invalid pointer.",
);
}
}
}
+152
View File
@@ -0,0 +1,152 @@
import type { GameRuntime } from "../app/runtime";
import type { Button } from "../input/types";
import { CrystalAssets } from "./rom/assets";
import { GameWorld } from "./world/world";
import type { Direction } from "./world/types";
import { WorldRenderer } from "./rendering/renderer";
export class NativeRuntime implements GameRuntime {
readonly id = "crystal-native-world-v1";
world?: GameWorld;
private renderer?: WorldRenderer;
private pressed = new Set<Button>();
private raf = 0;
private lastTime = 0;
private elapsed = 0;
private animation?: { x: number; y: number; elapsed: number };
private frame = 0;
constructor(
private onUpdate: (world: GameWorld) => void,
private onError: (error: unknown) => void,
private player: () => { name: string; avatar: "chris" | "kris" },
) {}
async load(bytes: Uint8Array, canvas: HTMLCanvasElement) {
this.dispose();
const assets = new CrystalAssets(bytes);
const world = new GameWorld(assets);
const player = this.player();
world.name = player.name;
world.avatar = player.avatar;
this.world = world;
this.renderer = new WorldRenderer(assets, canvas);
this.publish();
}
play() {
if (!this.world || this.raf) return;
this.lastTime = 0;
this.raf = requestAnimationFrame(this.tick);
}
pause() {
cancelAnimationFrame(this.raf);
this.raf = 0;
this.lastTime = 0;
this.pressed.clear();
this.elapsed = 0;
this.animation = undefined;
if (this.world && this.renderer) this.renderer.render(this.world);
}
setButton(button: Button, pressed: boolean) {
if (!pressed) {
this.pressed.delete(button);
return;
}
if (this.pressed.has(button) || !this.world) return;
this.pressed.add(button);
if (button === "a") {
this.animation = undefined;
this.world.interact();
this.publish();
}
if (button === "b" && this.world.dialogue) {
this.world.advanceDialogue();
this.publish();
}
if (button === "start" || button === "select") {
this.world.notice =
"Use Save session or Export backup below. Party, pack and Pokégear menus are not ported yet.";
this.onUpdate(this.world);
}
if (["up", "down", "left", "right"].includes(button) && !this.animation)
this.elapsed = 8 / 60;
}
private tick = (now: number) => {
try {
const world = this.world!;
const delta = this.lastTime
? Math.min((now - this.lastTime) / 1000, 0.05)
: 0;
this.lastTime = now;
this.elapsed += delta;
if (this.animation) {
this.animation.elapsed += delta;
if (this.animation.elapsed >= 8 / 60) this.animation = undefined;
}
const direction = [...this.pressed]
.reverse()
.find((button) => ["up", "down", "left", "right"].includes(button)) as
Direction | undefined;
if (
direction &&
!world.dialogue &&
this.elapsed >= 8 / 60 &&
!this.animation
) {
this.elapsed = 0;
const { x, y, mapId } = world;
if (world.move(direction) && world.mapId === mapId) {
this.animation = { x: x * 16, y: y * 16, elapsed: 0 };
this.frame = (this.frame + 1) % 4;
}
this.onUpdate(world);
}
const progress = this.animation ? this.animation.elapsed / (8 / 60) : 1;
const x = this.animation
? this.animation.x + (world.x * 16 - this.animation.x) * progress
: world.x * 16;
const y = this.animation
? this.animation.y + (world.y * 16 - this.animation.y) * progress
: world.y * 16;
this.renderer!.render(
world,
x,
y,
this.animation ? (this.frame % 2 ? 1 : 3) : 0,
);
this.raf = requestAnimationFrame(this.tick);
} catch (error) {
// Stop without rendering again: the renderer may be what failed.
cancelAnimationFrame(this.raf);
this.raf = 0;
this.lastTime = 0;
this.pressed.clear();
this.animation = undefined;
this.elapsed = 0;
this.onError(error);
}
};
private publish() {
if (this.world) {
this.renderer?.render(this.world);
this.onUpdate(this.world);
}
}
captureState() {
if (!this.world) throw new Error("Load your ROM first.");
return new TextEncoder().encode(JSON.stringify(this.world.save()));
}
restoreState(bytes: Uint8Array) {
if (!this.world || bytes.length > 64_000)
throw new Error("Invalid native save size.");
this.world.restore(
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
);
this.animation = undefined;
this.pressed.clear();
this.publish();
}
dispose() {
this.pause();
this.world = undefined;
this.renderer = undefined;
}
}
+208
View File
@@ -0,0 +1,208 @@
import { RomReader } from "../rom/reader";
import { SYMBOLS } from "../rom/profile";
import { readText } from "./text";
export interface ScriptState {
events: Set<number>;
flags: Set<number>;
variables: Record<number, number>;
}
export interface ScriptResult extends ScriptState {
pages: string[];
facePlayer: boolean;
turns: { object: number; direction: number }[];
}
export class UnsupportedScript extends Error {
constructor(
readonly offset: number,
readonly opcode: number,
) {
super(
`Script command 0x${opcode.toString(16)} at 0x${offset.toString(16)} is not ported.`,
);
}
}
/** A native interpreter for the game's own event format, NOT GB assembly.
* Evaluate on a private draft. Unknown commands abort the whole interaction;
* callers commit only after successful completion and dialogue acknowledgement. */
export function runScript(
reader: RomReader,
start: number,
state: ScriptState,
name: string,
): ScriptResult {
const result: ScriptResult = {
events: new Set(state.events),
flags: new Set(state.flags),
variables: { ...state.variables },
pages: [],
facePlayer: false,
turns: [],
};
const stack: number[] = [];
let p = start,
value = 0;
const byte = () => reader.byte(p++);
const word = () => {
const value = reader.word(p);
p += 2;
return value;
};
const near = () => {
const value = reader.near(p, reader.bank(p));
p += 2;
return value;
};
const far = () => {
const value = reader.far(p);
p += 3;
return value;
};
const text = (address: number) => {
const content = readText(reader, address, name);
if (content) result.pages.push(...content.split("\n\n"));
if (result.pages.length > 64) throw new Error("Too many dialogue pages.");
};
for (let steps = 0; steps < 2048; steps++) {
const offset = p,
opcode = byte();
switch (opcode) {
case 0x00: {
const address = near();
stack.push(p);
p = address;
break;
}
case 0x01: {
const address = far();
stack.push(p);
p = address;
break;
}
case 0x03:
p = near();
break;
case 0x04:
p = far();
break;
case 0x06:
case 0x07:
case 0x0a:
case 0x0b: {
const compared = byte(),
address = near();
const match =
opcode === 6
? value === compared
: opcode === 7
? value !== compared
: opcode === 10
? value > compared
: value < compared;
if (match) p = address;
break;
}
case 0x08:
case 0x09: {
const address = near();
if (Boolean(value) === (opcode === 9)) p = address;
break;
}
case 0x0c:
case 0x0d: {
const index = word();
if (index >= 52) throw new Error("Invalid standard script.");
if (opcode === 0x0d) stack.push(p);
p = reader.far(SYMBOLS.standardScripts + index * 3);
break;
}
case 0x15:
value = byte();
break;
case 0x16:
value = (value + byte()) & 255;
break;
case 0x31:
value = Number(result.events.has(word()));
break;
case 0x32:
result.events.delete(word());
break;
case 0x33:
result.events.add(word());
break;
case 0x34:
value = Number(result.flags.has(word()));
break;
case 0x35:
result.flags.delete(word());
break;
case 0x36:
result.flags.add(word());
break;
case 0x47:
case 0x49:
case 0x54:
case 0x55:
break; // handled by native dialogue presentation
case 0x4b:
text(far());
break;
case 0x4c:
text(near());
break;
case 0x51:
result.facePlayer = true;
text(near());
return result;
case 0x52:
text(far());
return result;
case 0x53:
text(near());
return result;
case 0x6b:
result.facePlayer = true;
break;
case 0x6d: {
const sprite = byte();
result.variables[sprite] = byte();
break;
}
case 0x76:
result.turns.push({ object: byte(), direction: byte() });
break;
// Audio and delays are presentation events. Native sound sequencing is a
// separate pending subsystem; consuming their operands keeps data aligned.
case 0x7f:
case 0x85:
word();
break;
case 0x81:
word();
byte();
break;
case 0x82:
case 0x83:
case 0x86:
case 0x87:
case 0x88:
break;
case 0x8b:
byte();
break;
case 0x90:
case 0x91:
if (!stack.length) return result;
p = stack.pop()!;
break;
case 0x93:
return result;
default:
throw new UnsupportedScript(offset, opcode);
}
if (stack.length > 16) throw new Error("Script call depth exceeded.");
}
throw new Error("Script instruction budget exceeded.");
}
+127
View File
@@ -0,0 +1,127 @@
import { RomReader } from "../rom/reader";
const CHARACTERS: Record<number, string> = {
0x1f: " ",
0x22: "\n",
0x25: "",
0x49: "MOM",
0x4a: "Pokémon",
0x4b: "\n",
0x4c: "\n",
0x4e: "\n",
0x4f: "\n",
0x51: "\n\n",
0x53: "???",
0x54: "POKé",
0x55: "\n",
0x56: "……",
0x5b: "PC",
0x5c: "TM",
0x5d: "TRAINER",
0x5e: "ROCKET",
0x70: "PO",
0x71: "Ké",
0x72: "“",
0x73: "”",
0x74: "·",
0x75: "…",
0x7f: " ",
0x9a: "(",
0x9b: ")",
0x9c: ":",
0x9d: ";",
0x9e: "[",
0x9f: "]",
0xc0: "Ä",
0xc1: "Ö",
0xc2: "Ü",
0xc3: "ä",
0xc4: "ö",
0xc5: "ü",
0xd0: "'d",
0xd1: "'l",
0xd2: "'m",
0xd3: "'r",
0xd4: "'s",
0xd5: "'t",
0xd6: "'v",
0xdf: "←",
0xe0: "'",
0xe1: "POKé",
0xe2: "MON",
0xe3: "-",
0xe6: "?",
0xe7: "!",
0xe8: ".",
0xe9: "&",
0xea: "é",
0xeb: "→",
0xec: "▷",
0xed: "▶",
0xee: "▼",
0xef: "♂",
0xf0: "¥",
0xf1: "×",
0xf2: ".",
0xf3: "/",
0xf4: ",",
0xf5: "♀",
};
export class UnsupportedText extends Error {
constructor(
readonly offset: number,
readonly opcode: number,
) {
super(
`Text command 0x${opcode.toString(16)} at 0x${offset.toString(16)} is not ported.`,
);
}
}
export function readText(
reader: RomReader,
start: number,
playerName: string,
depth = 0,
): string {
if (depth > 8) throw new Error("Text calls exceed the recursion limit.");
let p = start,
output = "";
for (let count = 0; count < 8192; count++) {
const commandOffset = p,
command = reader.byte(p++);
if (command === 0x50) return output.trim();
if (command === 0x16) {
output += readText(
reader,
reader.near(p, reader.byte(p + 2)),
playerName,
depth + 1,
);
p += 3;
continue;
}
if (command !== 0) throw new UnsupportedText(commandOffset, command);
let ended = false;
for (let length = 0; length < 8192; length++) {
const offset = p,
code = reader.byte(p++);
if (code === 0x57 || code === 0x58) return output.trim();
if (code === 0x50) {
ended = true;
break;
}
if (code === 0x52 || code === 0x14) output += playerName;
else if (code >= 0x80 && code <= 0x99)
output += String.fromCharCode(65 + code - 0x80);
else if (code >= 0xa0 && code <= 0xb9)
output += String.fromCharCode(97 + code - 0xa0);
else if (code >= 0xf6) output += String(code - 0xf6);
else if (CHARACTERS[code] !== undefined) output += CHARACTERS[code];
else throw new UnsupportedText(offset, code);
}
if (!ended) throw new Error("Unterminated text.");
}
throw new Error("Text command limit exceeded.");
}
+77
View File
@@ -0,0 +1,77 @@
export type Direction = "down" | "up" | "left" | "right";
export const VECTORS: Record<Direction, readonly [number, number]> = {
down: [0, 1],
up: [0, -1],
left: [-1, 0],
right: [1, 0],
};
export const DIRECTION_ORDER: Direction[] = ["down", "up", "left", "right"];
export interface Warp {
x: number;
y: number;
targetMap: string;
targetWarp: number;
}
export interface BackgroundEvent {
x: number;
y: number;
kind: number;
script: number;
}
export interface MapObject {
id: number;
sprite: number;
x: number;
y: number;
movement: number;
palette: number;
kind: number;
script: number;
eventFlag: number;
hour1: number;
hour2: number;
}
export interface MapConnection {
direction: Direction;
targetMap: string;
x: number;
y: number;
}
export interface GameMap {
id: string;
group: number;
number: number;
name: string;
width: number;
height: number;
border: number;
tileset: number;
environment: number;
paletteMode: number;
blocks: Uint8Array;
warps: Warp[];
backgroundEvents: BackgroundEvent[];
objects: MapObject[];
connections: MapConnection[];
sceneScripts: number[];
callbacks: { kind: number; script: number }[];
coordinateEvents: { scene: number; x: number; y: number; script: number }[];
}
export interface Tileset {
graphics: Uint8Array;
metatiles: Uint8Array;
collision: Uint8Array;
paletteMap: Uint8Array;
}
export type Palette = readonly (readonly [number, number, number])[];
export interface Sprite {
graphics: Uint8Array;
palette: number;
walking: boolean;
}
export interface FacingTile {
x: number;
y: number;
attributes: number;
tile: number;
}
+370
View File
@@ -0,0 +1,370 @@
import { CrystalAssets } from "../rom/assets";
import { INITIAL_POSITION, NATIVE_MAPS, SYMBOLS } from "../rom/profile";
import {
runScript,
type ScriptResult,
type ScriptState,
} from "../scripts/interpreter";
import {
DIRECTION_ORDER,
VECTORS,
type Direction,
type MapObject,
} from "./types";
export interface NativeSave {
format: "crystal-native";
version: 1;
map: string;
x: number;
y: number;
direction: Direction;
name: string;
avatar: "chris" | "kris";
tilesWalked: number;
events: number[];
flags: number[];
variables: Record<number, number>;
}
/** Deterministic native world rules. Rendering, browser APIs and emulator
* registers are deliberately absent; callers provide time and input. */
export class GameWorld {
mapId: string = INITIAL_POSITION.map;
x: number = INITIAL_POSITION.x;
y: number = INITIAL_POSITION.y;
direction: Direction = "down";
name = "CHRIS";
avatar: "chris" | "kris" = "chris";
tilesWalked = 0;
state: ScriptState = { events: new Set(), flags: new Set(), variables: {} };
dialogue:
{ pages: string[]; index: number; result: ScriptResult } | undefined;
notice = "";
diagnostic = "";
private objectDirections = new Map<number, Direction>();
constructor(
readonly assets: CrystalAssets,
private now: () => Date = () => new Date(),
) {
// Port the original initialization event table through the script engine;
// this supplies NPC visibility flags without distributing a copied table.
const initial = assets.reader.far(SYMBOLS.standardScripts + 24 * 3);
this.state = runScript(assets.reader, initial, this.state, this.name);
assets.map(this.mapId);
}
get map() {
return this.assets.map(this.mapId);
}
get timeOfDay(): number {
const mode = this.map.paletteMode;
if (mode === 1) return 1;
if (mode === 2) return 2;
if (mode === 3) return 0;
if (mode === 4) return 3;
const hour = this.now().getHours();
return hour >= 4 && hour < 10 ? 0 : hour >= 10 && hour < 18 ? 1 : 2;
}
objects(): MapObject[] {
const hour = this.now().getHours(),
timeMask = 1 << this.timeOfDay;
return this.map.objects.filter((object) => {
if (
object.eventFlag !== 0xffff &&
this.state.events.has(object.eventFlag)
)
return false;
if (!this.assets.sprite(object.sprite)) return false;
if (object.hour1 === 0xff)
return object.hour2 === 0xff || Boolean(object.hour2 & timeMask);
if (object.hour1 === object.hour2) return true;
return object.hour1 < object.hour2
? hour >= object.hour1 && hour < object.hour2
: hour >= object.hour1 || hour < object.hour2;
});
}
objectDirection(object: MapObject): Direction {
return (
this.objectDirections.get(object.id) ??
DIRECTION_ORDER[object.movement - 6] ??
"down"
);
}
private clearNotice() {
this.notice = "";
this.diagnostic = "";
}
private canStand(x: number, y: number): boolean {
if (x < 0 || y < 0 || x >= this.map.width || y >= this.map.height)
return false;
if (this.assets.permission(this.assets.collision(this.map, x, y)) !== 0)
return false;
return !this.objects().some((object) => object.x === x && object.y === y);
}
move(direction: Direction): boolean {
if (this.dialogue) return false;
this.clearNotice();
this.direction = direction;
const [dx, dy] = VECTORS[direction],
x = this.x + dx,
y = this.y + dy;
if (x < 0 || y < 0 || x >= this.map.width || y >= this.map.height) {
if (
this.map.connections.some(
(connection) => connection.direction === direction,
)
)
this.notice = "Routes beyond New Bark Town are not ported yet.";
return false;
}
const collision = this.assets.collision(this.map, x, y);
// Forced movement, surfing and ledges need their own translated rules.
// Refuse them rather than treating a land permission as ordinary walking.
if ([0x30, 0x40, 0x50, 0x60, 0xa0].includes(collision & 0xf0)) {
this.notice = "This movement type is not ported yet.";
return false;
}
if (!this.canStand(x, y)) return false;
// Directional edge walls: up/down/left/right encoded as hardware direction IDs.
const edges: Record<number, Direction[]> = {
0xb0: ["right"],
0xb1: ["left"],
0xb2: ["up"],
0xb3: ["down"],
0xb4: ["down", "right"],
0xb5: ["down", "left"],
0xb6: ["up", "right"],
0xb7: ["up", "left"],
};
const source = this.assets.collision(this.map, this.x, this.y);
const opposite: Record<Direction, Direction> = {
down: "up",
up: "down",
left: "right",
right: "left",
};
if (
edges[source]?.includes(direction) ||
edges[collision]?.includes(opposite[direction])
)
return false;
const warp = this.map.warps.find((warp) => warp.x === x && warp.y === y);
if (warp) {
if (
!Object.hasOwn(NATIVE_MAPS, warp.targetMap) ||
warp.targetWarp === 0 ||
warp.targetWarp === 255
) {
this.notice = "This destination is not ported yet.";
return false;
}
const target = this.assets.map(warp.targetMap),
arrival = target.warps[warp.targetWarp - 1];
if (!arrival) throw new Error("Warp destination is invalid.");
this.mapId = target.id;
this.x = arrival.x;
this.y = arrival.y;
this.objectDirections.clear();
this.direction = "down";
const arrivalCollision = this.assets.collision(target, this.x, this.y);
const exitY =
arrivalCollision === 0x70
? this.y - 1
: arrivalCollision === 0x71
? this.y + 1
: this.y;
if (exitY !== this.y && this.canStand(this.x, exitY)) {
this.direction = exitY < this.y ? "up" : "down";
this.y = exitY;
}
} else {
this.x = x;
this.y = y;
}
this.tilesWalked++;
return true;
}
interact() {
if (this.dialogue) {
this.advanceDialogue();
return;
}
this.clearNotice();
const [dx, dy] = VECTORS[this.direction];
let x = this.x + dx,
y = this.y + dy;
const collision = this.assets.collision(this.map, x, y);
if (collision === 0x90) {
x += dx;
y += dy;
}
const object = this.objects().find(
(object) => object.x === x && object.y === y,
);
let script: number | undefined;
if (object) {
if (object.kind !== 0) {
this.notice = "This interaction is not ported yet.";
return;
}
script = object.script;
} else {
const event = this.map.backgroundEvents.find(
(event) => event.x === x && event.y === y,
);
if (event) {
if (event.kind >= 5) {
this.notice = "This conditional interaction is not ported yet.";
return;
}
const requiredDirection = ["up", "down", "right", "left"][
event.kind - 1
];
if (event.kind && requiredDirection !== this.direction) return;
script = event.script;
} else {
const standard: Record<number, number> = {
0x91: 2,
0x97: 9,
0x9d: 8,
0x96: 6,
0x9f: 5,
};
if (standard[collision] !== undefined && this.direction === "up")
script = this.assets.reader.far(
SYMBOLS.standardScripts + standard[collision] * 3,
);
}
}
if (script === undefined) {
this.notice = "Nothing to interact with here.";
return;
}
try {
const result = runScript(
this.assets.reader,
script,
this.state,
this.name,
);
if (result.facePlayer && object) {
const opposite: Record<Direction, Direction> = {
down: "up",
up: "down",
left: "right",
right: "left",
};
this.objectDirections.set(object.id, opposite[this.direction]);
}
if (result.pages.length)
this.dialogue = { pages: result.pages, index: 0, result };
else this.commit(result);
} catch (error) {
this.notice =
"This interaction needs game logic that has not been ported yet. Your progress is unchanged.";
this.diagnostic = error instanceof Error ? error.message : String(error);
}
}
advanceDialogue() {
if (!this.dialogue) return;
if (++this.dialogue.index < this.dialogue.pages.length) return;
this.commit(this.dialogue.result);
this.dialogue = undefined;
}
private commit(result: ScriptResult) {
this.state = {
events: result.events,
flags: result.flags,
variables: result.variables,
};
for (const turn of result.turns) {
if (DIRECTION_ORDER[turn.direction])
this.objectDirections.set(turn.object, DIRECTION_ORDER[turn.direction]);
}
}
save(): NativeSave {
// Incomplete interactions are replayed after reload; no partial quest writes.
return {
format: "crystal-native",
version: 1,
map: this.mapId,
x: this.x,
y: this.y,
direction: this.direction,
name: this.name,
avatar: this.avatar,
tilesWalked: this.tilesWalked,
events: [...this.state.events].sort((a, b) => a - b),
flags: [...this.state.flags].sort((a, b) => a - b),
variables: { ...this.state.variables },
};
}
restore(value: unknown) {
const save = value as NativeSave;
const integer = (
value: unknown,
min: number,
max: number,
): value is number =>
Number.isInteger(value) && Number(value) >= min && Number(value) <= max;
const ids = (value: unknown): value is number[] =>
Array.isArray(value) &&
value.length <= 2048 &&
value.every((value) => integer(value, 0, 0xffff)) &&
new Set(value).size === value.length;
if (
!save ||
save.format !== "crystal-native" ||
save.version !== 1 ||
!Object.hasOwn(NATIVE_MAPS, save.map) ||
!integer(save.x, 0, 127) ||
!integer(save.y, 0, 127) ||
!DIRECTION_ORDER.includes(save.direction) ||
typeof save.name !== "string" ||
!/^[A-Za-z0-9 -]{1,7}$/.test(save.name) ||
!["chris", "kris"].includes(save.avatar) ||
!integer(save.tilesWalked, 0, Number.MAX_SAFE_INTEGER) ||
!ids(save.events) ||
!ids(save.flags) ||
!save.variables ||
typeof save.variables !== "object" ||
Array.isArray(save.variables) ||
Object.entries(save.variables).some(
([id, value]) =>
!/^\d+$/.test(id) ||
!integer(Number(id), 0, 255) ||
!integer(value, 0, 255),
)
) {
throw new Error(
"Invalid native save. Emulator snapshots cannot be imported into this port.",
);
}
const map = this.assets.map(save.map);
if (
save.x >= map.width ||
save.y >= map.height ||
this.assets.permission(this.assets.collision(map, save.x, save.y)) !== 0
)
throw new Error("The saved position is outside the walkable map.");
// Validate completely before changing live state.
this.mapId = save.map;
this.x = save.x;
this.y = save.y;
this.direction = save.direction;
this.name = save.name;
this.avatar = save.avatar;
this.tilesWalked = save.tilesWalked;
this.state = {
events: new Set(save.events),
flags: new Set(save.flags),
variables: { ...save.variables },
};
this.dialogue = undefined;
this.objectDirections.clear();
this.clearNotice();
}
}
+165
View File
@@ -0,0 +1,165 @@
import { type Button } from "./types";
import { InputController } from "./controller";
const KEYS: Record<string, Button> = {
ArrowUp: "up",
ArrowDown: "down",
ArrowLeft: "left",
ArrowRight: "right",
KeyX: "a",
KeyZ: "b",
Enter: "start",
ShiftLeft: "select",
ShiftRight: "select",
};
export function bindControls(
root: HTMLElement,
input: InputController,
active: () => boolean,
unlock: () => void,
) {
const abort = new AbortController();
const options = { signal: abort.signal };
const buttons = [
...root.querySelectorAll<HTMLButtonElement>("[data-game-button]"),
];
const refresh = () =>
buttons.forEach((el) => {
const pressed = input.isPressed(el.dataset.gameButton as Button);
el.classList.toggle("pressed", pressed);
el.setAttribute("aria-pressed", String(pressed));
});
const release = () => {
input.releaseAll();
refresh();
};
for (const el of buttons) {
const focusedSource = `focused:${el.dataset.gameButton}`;
el.addEventListener(
"keydown",
(event) => {
if (!active() || !["Space", "Enter"].includes(event.code)) return;
event.preventDefault();
unlock();
input.press(focusedSource, el.dataset.gameButton as Button);
refresh();
},
options,
);
el.addEventListener(
"keyup",
(event) => {
if (!["Space", "Enter"].includes(event.code)) return;
event.preventDefault();
input.release(focusedSource);
refresh();
},
options,
);
el.addEventListener(
"blur",
() => {
input.release(focusedSource);
refresh();
},
options,
);
el.addEventListener(
"pointerdown",
(event) => {
if (!active() || (event.pointerType === "mouse" && event.button !== 0))
return;
event.preventDefault();
unlock();
el.setPointerCapture(event.pointerId);
input.press(
`pointer:${event.pointerId}`,
el.dataset.gameButton as Button,
);
refresh();
},
options,
);
// Slide between D-pad directions without lifting the finger.
el.addEventListener(
"pointermove",
(event) => {
if (!el.hasPointerCapture(event.pointerId) || !el.closest(".dpad"))
return;
const target = document
.elementFromPoint(event.clientX, event.clientY)
?.closest<HTMLElement>("[data-game-button]");
if (target?.closest(".dpad"))
input.press(
`pointer:${event.pointerId}`,
target.dataset.gameButton as Button,
);
else input.release(`pointer:${event.pointerId}`);
refresh();
},
options,
);
for (const name of [
"pointerup",
"pointercancel",
"lostpointercapture",
] as const) {
el.addEventListener(
name,
(event) => {
input.release(`pointer:${event.pointerId}`);
refresh();
},
options,
);
}
el.addEventListener(
"contextmenu",
(event) => event.preventDefault(),
options,
);
}
window.addEventListener(
"keydown",
(event) => {
if (!active() || event.ctrlKey || event.metaKey || event.altKey) return;
if (
(event.target as HTMLElement)?.closest(
"input, textarea, select, button, a, [contenteditable]",
)
)
return;
const button = KEYS[event.code];
if (!button) return;
event.preventDefault();
unlock();
input.press(`key:${event.code}`, button);
refresh();
},
options,
);
window.addEventListener(
"keyup",
(event) => {
input.release(`key:${event.code}`);
refresh();
},
options,
);
window.addEventListener("blur", release, options);
document.addEventListener(
"visibilitychange",
() => {
if (document.hidden) release();
},
options,
);
return {
release,
dispose: () => {
release();
abort.abort();
},
};
}
+30
View File
@@ -0,0 +1,30 @@
import { BUTTONS, type Button } from "./types";
/** Multiple fingers/keys can hold a button independently. */
export class InputController {
private sources = new Map<string, Button>();
constructor(private send: (button: Button, pressed: boolean) => void) {}
press(source: string, button: Button) {
if (this.sources.get(source) === button) return;
this.release(source);
const alreadyPressed = this.isPressed(button);
this.sources.set(source, button);
if (!alreadyPressed) this.send(button, true);
}
release(source: string) {
const button = this.sources.get(source);
this.sources.delete(source);
if (button && !this.isPressed(button)) this.send(button, false);
}
isPressed(button: Button) {
return [...this.sources.values()].includes(button);
}
releaseAll() {
this.sources.clear();
for (const button of BUTTONS) this.send(button, false);
}
}
+11
View File
@@ -0,0 +1,11 @@
export const BUTTONS = [
"up",
"down",
"left",
"right",
"a",
"b",
"start",
"select",
] as const;
export type Button = (typeof BUTTONS)[number];
+278
View File
@@ -0,0 +1,278 @@
import "./ui/styles.css";
import { renderShell } from "./ui/shell";
import { NativeRuntime } from "./game/runtime";
import type { GameWorld } from "./game/world/world";
import { GameSession } from "./app/session";
import { InputController } from "./input/controller";
import { bindControls } from "./input/bindings";
import { validateRom } from "./rom/validate";
import { BrowserSaveRepository, encodeSave } from "./storage/saves";
import { acquireSessionLock } from "./platform/session-lock";
const root = document.querySelector<HTMLElement>("#app")!;
renderShell(root);
const get = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
const canvas = get<HTMLCanvasElement>("game-screen");
const status = get("status");
const saveStatus = get("save-status");
let busy = false;
let lockRelease: (() => void) | undefined;
let saving: Promise<void> | undefined;
function showWorld(world: GameWorld) {
get<HTMLInputElement>("player-name").value = world.name;
get<HTMLSelectElement>("player-avatar").value = world.avatar;
get("location").textContent = world.map.name;
get("tiles-walked").textContent = `${world.tilesWalked} tiles walked`;
get("game-notice").textContent = world.notice;
get("native-dialogue").hidden = !world.dialogue;
get("dialogue-text").textContent =
world.dialogue?.pages[world.dialogue.index] ?? "";
get("dialogue-page").textContent = world.dialogue
? `${world.dialogue.index + 1} / ${world.dialogue.pages.length}`
: "";
// Coordinates provide a stable accessible description and a test observation
// point, without exposing mutable game state or a production debug API.
canvas.dataset.map = world.mapId;
canvas.dataset.x = String(world.x);
canvas.dataset.y = String(world.y);
canvas.setAttribute(
"aria-label",
`${world.map.name}. Position ${world.x}, ${world.y}. Arrow keys move. X interacts. Z continues dialogue.`,
);
}
const runtime = new NativeRuntime(
showWorld,
(error) => {
session.state = "error";
controls.release();
showError(error);
update();
},
() => ({
name:
get<HTMLInputElement>("player-name")
.value.trim()
.replace(/[^A-Za-z0-9 -]/g, "")
.slice(0, 7) || "CHRIS",
avatar:
get<HTMLSelectElement>("player-avatar").value === "kris"
? "kris"
: "chris",
}),
);
const session = new GameSession(
runtime,
new BrowserSaveRepository("crystal-native-port"),
);
const input = new InputController((button, pressed) =>
runtime.setButton(button, pressed),
);
const controls = bindControls(
root,
input,
() => session.state === "playing" && !busy,
() => {},
);
const ready = () => ["paused", "playing"].includes(session.state);
function showError(error: unknown) {
status.textContent =
error instanceof Error
? error.message
: "Something went wrong. Please try again.";
status.classList.add("error");
}
function message(text: string) {
status.textContent = text;
status.classList.remove("error");
}
function update() {
const playing = session.state === "playing";
root.classList.toggle("has-game", ready());
for (const id of ["play", "save", "backup", "import-save", "screen-resume"]) {
get<HTMLButtonElement>(id).disabled = busy || !ready();
}
root
.querySelectorAll<HTMLButtonElement>("[data-game-button]")
.forEach((button) => {
button.disabled = !playing || busy;
});
get<HTMLButtonElement>("play").textContent = playing ? "Pause" : "Play";
get("power-label").textContent = playing
? "PLAYING"
: ready()
? "PAUSED"
: "STANDBY";
get("power-light").classList.toggle("on", playing);
get("screen-empty").hidden = ready();
get("pause-overlay").hidden = session.state !== "paused";
get<HTMLInputElement>("rom-file").disabled = busy;
get("rom-label").classList.toggle("disabled", busy);
get<HTMLInputElement>("player-name").disabled = busy || ready();
get<HTMLSelectElement>("player-avatar").disabled = busy || ready();
get<HTMLButtonElement>("dialogue-next").disabled =
busy || session.state !== "playing";
}
async function save() {
if (!ready()) return;
// Capture again after an earlier write, so pausing never drops the last frame.
if (saving) {
await saving.catch(() => {});
return save();
}
saving = session
.save()
.then((time) => {
saveStatus.textContent = `Session saved on this device · ${new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
saveStatus.classList.remove("error");
})
.catch((error) => {
saveStatus.textContent =
"Session could not be saved. Export a backup to keep your progress.";
saveStatus.classList.add("error");
throw error;
})
.finally(() => {
saving = undefined;
});
return saving;
}
function pause() {
if (session.state !== "playing") return;
session.pause();
controls.release();
update();
void save().catch(showError);
}
function play() {
if (busy) return;
session.play();
canvas.focus({ preventScroll: true });
message(
"Native exploration is running. Story progression and battles are not ported yet.",
);
update();
}
get<HTMLInputElement>("rom-file").addEventListener("change", async (event) => {
const field = event.target as HTMLInputElement;
const file = field.files?.[0];
field.value = "";
if (!file || busy) return;
busy = true;
controls.release();
update();
message("Checking your cartridge locally…");
try {
const rom = await validateRom(file);
if (!lockRelease)
lockRelease = await acquireSessionLock("crystal-native-session");
if (ready()) {
session.pause();
controls.release();
await saving;
await save();
}
const restored = await session.load(rom, canvas);
message(
restored
? "Your saved session is ready. Press Play to continue."
: "Cartridge verified. Press Play to explore your room and New Bark Town.",
);
} catch (error) {
showError(error);
} finally {
busy = false;
update();
}
});
get("play").addEventListener("click", () =>
session.state === "playing" ? pause() : play(),
);
get("screen-resume").addEventListener("click", play);
get("dialogue-next").addEventListener("click", () => {
if (session.state !== "playing") return;
runtime.world?.advanceDialogue();
if (runtime.world) showWorld(runtime.world);
canvas.focus({ preventScroll: true });
});
get("save").addEventListener("click", () => {
void save().catch(showError);
});
function download(contents: BlobPart, filename: string, type: string) {
const url = URL.createObjectURL(new Blob([contents], { type }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
get("backup").addEventListener("click", () => {
try {
download(
encodeSave(session.snapshot()),
`crystal-${new Date().toISOString().replace(/[:.]/g, "-")}.crystal-save.json`,
"application/json",
);
message("Session backup exported. Keep it somewhere safe.");
} catch (error) {
showError(error);
}
});
get("import-save").addEventListener("click", () => {
pause();
get<HTMLInputElement>("save-file").click();
});
get<HTMLInputElement>("save-file").addEventListener("change", async (event) => {
const field = event.target as HTMLInputElement;
const file = field.files?.[0];
field.value = "";
if (!file || busy) return;
if (file.size > 2_000_000) {
showError(new Error("This backup is too large."));
return;
}
if (
!window.confirm(
"Replace your current session with this backup? Export your current session first if you want to keep it.",
)
)
return;
busy = true;
update();
try {
await saving;
await session.importSave(await file.text());
message("Backup restored. Press Play to continue.");
saveStatus.textContent = "Imported session saved on this device.";
} catch (error) {
showError(error);
} finally {
busy = false;
update();
}
});
get("help").addEventListener("click", () => {
pause();
get<HTMLDialogElement>("help-dialog").showModal();
});
get("close-help").addEventListener("click", () =>
get<HTMLDialogElement>("help-dialog").close(),
);
window.addEventListener("blur", pause);
document.addEventListener("visibilitychange", () => {
if (document.hidden) pause();
});
window.addEventListener("pagehide", () => {
pause();
});
setInterval(() => {
if (ready() && !busy) void save().catch(() => {});
}, 10_000);
update();
+24
View File
@@ -0,0 +1,24 @@
/** Only one tab may write this player's save slot. Fail closed if unavailable. */
export async function acquireSessionLock(
name = "crystal-player-session",
): Promise<() => void> {
if (!navigator.locks)
throw new Error(
"This browser needs Web Locks support to protect your saves. Use a recent browser over HTTPS or localhost.",
);
return new Promise((resolve, reject) => {
void navigator.locks
.request(name, { ifAvailable: true }, (lock) => {
if (!lock) {
reject(
new Error(
"Crystal is already open in another tab. Close that tab and try again.",
),
);
return;
}
return new Promise<void>((release) => resolve(release));
})
.catch(reject);
});
}
+251
View File
@@ -0,0 +1,251 @@
import "../ui/styles.css";
import { renderShell } from "./shell";
import { BinjgbEmulator } from "../emulator/binjgb";
import { GameSession } from "../app/session";
import { InputController } from "../input/controller";
import { bindControls } from "../input/bindings";
import { validateRom } from "../rom/validate";
import { BrowserSaveRepository, encodeSave } from "../storage/saves";
import { acquireSessionLock } from "../platform/session-lock";
const root = document.querySelector<HTMLElement>("#app")!;
renderShell(root);
const get = <T extends HTMLElement>(id: string) =>
document.getElementById(id) as T;
const canvas = get<HTMLCanvasElement>("game-screen");
const status = get("status");
const saveStatus = get("save-status");
let busy = false;
let muted = true;
let lockRelease: (() => void) | undefined;
let saving: Promise<void> | undefined;
const emulator = new BinjgbEmulator((error) => {
session.state = "error";
controls.release();
showError(error);
update();
});
const session = new GameSession(emulator, new BrowserSaveRepository());
const input = new InputController((button, pressed) =>
emulator.setButton(button, pressed),
);
const controls = bindControls(
root,
input,
() => session.state === "playing" && !busy,
() => emulator.unlockAudio(),
);
const ready = () => ["paused", "playing"].includes(session.state);
function showError(error: unknown) {
status.textContent =
error instanceof Error
? error.message
: "Something went wrong. Please try again.";
status.classList.add("error");
}
function message(text: string) {
status.textContent = text;
status.classList.remove("error");
}
function update() {
const playing = session.state === "playing";
root.classList.toggle("has-game", ready());
for (const id of [
"play",
"sound",
"save",
"backup",
"import-save",
"export-battery",
"screen-resume",
]) {
get<HTMLButtonElement>(id).disabled = busy || !ready();
}
root
.querySelectorAll<HTMLButtonElement>("[data-game-button]")
.forEach((button) => {
button.disabled = !playing || busy;
});
get<HTMLButtonElement>("play").textContent = playing ? "Pause" : "Play";
get("power-label").textContent = playing
? "PLAYING"
: ready()
? "PAUSED"
: "STANDBY";
get("power-light").classList.toggle("on", playing);
get("screen-empty").hidden = ready();
get("pause-overlay").hidden = session.state !== "paused";
get<HTMLInputElement>("rom-file").disabled = busy;
get("rom-label").classList.toggle("disabled", busy);
}
async function save() {
if (!ready()) return;
// Capture again after an earlier write, so pausing never drops the last frame.
if (saving) {
await saving.catch(() => {});
return save();
}
saving = session
.save()
.then((time) => {
saveStatus.textContent = `Session saved on this device · ${new Date(time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
saveStatus.classList.remove("error");
})
.catch((error) => {
saveStatus.textContent =
"Session could not be saved. Export a backup to keep your progress.";
saveStatus.classList.add("error");
throw error;
})
.finally(() => {
saving = undefined;
});
return saving;
}
function pause() {
if (session.state !== "playing") return;
session.pause();
controls.release();
update();
void save().catch(showError);
}
function play() {
if (busy) return;
session.play();
canvas.focus({ preventScroll: true });
message("Your adventure is running.");
update();
}
get<HTMLInputElement>("rom-file").addEventListener("change", async (event) => {
const field = event.target as HTMLInputElement;
const file = field.files?.[0];
field.value = "";
if (!file || busy) return;
busy = true;
controls.release();
update();
message("Checking your cartridge locally…");
try {
const rom = await validateRom(file);
if (!lockRelease) lockRelease = await acquireSessionLock();
if (ready()) {
session.pause();
controls.release();
await saving;
await save();
}
const restored = await session.load(rom, canvas);
message(
restored
? "Your saved session is ready. Press Play to continue."
: "Cartridge verified. Press Play to begin.",
);
} catch (error) {
showError(error);
} finally {
busy = false;
update();
}
});
get("play").addEventListener("click", () =>
session.state === "playing" ? pause() : play(),
);
get("screen-resume").addEventListener("click", play);
get("sound").addEventListener("click", () => {
muted = !muted;
emulator.setMuted(muted);
get("sound").textContent = muted ? "Sound off" : "Sound on";
get("sound").setAttribute("aria-pressed", String(!muted));
});
get("save").addEventListener("click", () => {
void save().catch(showError);
});
function download(contents: BlobPart, filename: string, type: string) {
const url = URL.createObjectURL(new Blob([contents], { type }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
get("backup").addEventListener("click", () => {
try {
download(
encodeSave(session.snapshot()),
`crystal-${new Date().toISOString().replace(/[:.]/g, "-")}.crystal-save.json`,
"application/json",
);
message("Session backup exported. Keep it somewhere safe.");
} catch (error) {
showError(error);
}
});
get("export-battery").addEventListener("click", () => {
try {
download(
new Uint8Array(emulator.exportBattery()).buffer,
"crystal.sav",
"application/octet-stream",
);
} catch (error) {
showError(error);
}
});
get("import-save").addEventListener("click", () => {
pause();
get<HTMLInputElement>("save-file").click();
});
get<HTMLInputElement>("save-file").addEventListener("change", async (event) => {
const field = event.target as HTMLInputElement;
const file = field.files?.[0];
field.value = "";
if (!file || busy) return;
if (file.size > 2_000_000) {
showError(new Error("This backup is too large."));
return;
}
if (
!window.confirm(
"Replace your current session with this backup? Export your current session first if you want to keep it.",
)
)
return;
busy = true;
update();
try {
await saving;
await session.importSave(await file.text());
message("Backup restored. Press Play to continue.");
saveStatus.textContent = "Imported session saved on this device.";
} catch (error) {
showError(error);
} finally {
busy = false;
update();
}
});
get("help").addEventListener("click", () => {
pause();
get<HTMLDialogElement>("help-dialog").showModal();
});
get("close-help").addEventListener("click", () =>
get<HTMLDialogElement>("help-dialog").close(),
);
window.addEventListener("blur", pause);
document.addEventListener("visibilitychange", () => {
if (document.hidden) pause();
});
window.addEventListener("pagehide", () => {
pause();
});
setInterval(() => {
if (ready() && !busy) void save().catch(() => {});
}, 10_000);
update();
+51
View File
@@ -0,0 +1,51 @@
const key = (name: string, label: string, className = "") =>
`<button class="game-key ${className}" data-game-button="${name}" aria-label="${name === "a" || name === "b" ? name.toUpperCase() : name}" aria-pressed="false" disabled>${label}</button>`;
export function renderShell(root: HTMLElement) {
root.innerHTML = `
<header class="site-header">
<a class="brand" href="./" aria-label="Crystal home"><img src="${import.meta.env.BASE_URL}icon.svg" alt="" width="36" height="36">crystal<span>REFERENCE EMULATOR</span></a>
<button id="help" class="text-button">How to play <span aria-hidden="true">↗</span></button>
</header>
<main>
<section class="intro" aria-labelledby="page-title">
<p class="eyebrow"><span class="small-star">✧</span> YOUR CARTRIDGE. A NEW HOME.</p>
<h1 id="page-title">Original game.<br><em>Reference only.</em></h1>
<p class="intro-copy">Development reference for the native port.<br> Bring your own ROM. Pick up where you left off.</p>
<div class="load-card">
<div class="card-heading"><span class="step-number">01</span><h2>Bring your cartridge</h2></div>
<p>Pokémon Crystal<br><strong>English US/Europe · Revision 1.1</strong></p>
<label class="primary-button file-button" id="rom-label" for="rom-file"><span aria-hidden="true">+</span> Choose ROM <span aria-hidden="true">→</span></label>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream" />
<p class="privacy-note"><span aria-hidden="true">◇</span> Opened locally. Never uploaded.</p>
</div>
<div class="project-note"><span class="note-line"></span><p>A small beginning, built for more.<br><span>Touch menus, online linking, and walking features are planned.</span></p></div>
</section>
<section class="play-area" aria-label="Game player">
<div class="console">
<div class="console-top"><span>CRYSTAL / 01</span><span class="power"><i id="power-light"></i><span id="power-label">STANDBY</span></span></div>
<div class="screen-bezel">
<div class="screen">
<canvas id="game-screen" width="160" height="144" tabindex="0" aria-label="Game screen. Arrow keys move, X is A, Z is B, Enter is Start, Shift is Select."></canvas>
<div id="screen-empty" class="screen-empty"><div class="crystal-mark" aria-hidden="true">◇</div><h2>Your next chapter<br>starts here.</h2><p>Load your Crystal ROM to begin.</p><span class="screen-dots" aria-hidden="true">· · ·</span></div>
<div id="pause-overlay" class="pause-overlay" hidden><span>PAUSED</span><button id="screen-resume">Continue playing →</button></div>
</div>
<div class="screen-caption"><span>GAME BOY COLOR</span><span>160 × 144</span></div>
</div>
<div class="controls">
<div class="dpad" aria-label="Directional controls">${key("up", "▲")}${key("left", "◀")}<span class="dpad-center" aria-hidden="true"></span>${key("right", "▶")}${key("down", "▼")}</div>
<div class="action-keys">${key("b", "B")}${key("a", "A")}</div>
<div class="system-keys">${key("select", "SELECT")}${key("start", "START")}</div>
<div class="speaker" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
</div>
<div class="console-bottom"><span>MADE FOR THE WAY YOU PLAY</span><span>✧</span></div>
</div>
<div class="player-toolbar" aria-label="Player actions"><button id="play" disabled>Play</button><button id="sound" aria-pressed="false" disabled>Sound off</button><button id="save" disabled>Save session</button><button id="backup" disabled>Export backup</button></div>
<p id="status" class="status" role="status" aria-live="polite">Ready when you are. Choose your ROM to get started.</p>
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
<details class="save-tools"><summary>Save files & compatibility</summary><div><button id="import-save" disabled>Import session backup</button><button id="export-battery" disabled>Export in-game .sav</button><input id="save-file" class="file-input" type="file" accept=".json" /><p>Use the game’s SAVE menu before exporting a .sav. Session backups also preserve your current screen and clock state.</p><p>The game clock currently advances only while playing. Time while paused or closed is not added. Export a backup before clearing browser data.</p></div></details>
</section>
</main>
<footer><span>A personal cartridge, a pocket-sized world.</span><span>Independent project · No game assets included</span></footer>
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="eyebrow">GETTING STARTED</p><h2>Make yourself at home.</h2><p>Choose your own uncompressed English US/Europe revision 1.1 ROM, then press Play. ROMs stay in memory and must be selected again after a reload.</p><p>Use the onscreen controls, or focus the game screen and use:</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>A / B</dt><dd>X / Z</dd><dt>Start / Select</dt><dd>Enter / Shift</dd></dl><p>Sessions save every 10 seconds and when paused. Keep a downloaded backup: browser storage can be cleared. Switching apps pauses the game; tap Play to resume.</p><p>The original game menus remain intact for this first version. Internet Link and step counting are not available yet.</p></dialog>`;
}
+35
View File
@@ -0,0 +1,35 @@
export const CRYSTAL_ROM = {
name: "Pokémon Crystal · English US/Europe 1.1",
sha1: "f2f52230b536214ef7c9924f483392993e226cfb",
size: 2 * 1024 * 1024,
} as const;
export interface ValidatedRom {
bytes: Uint8Array;
sha1: string;
name: string;
}
export async function validateRom(file: File): Promise<ValidatedRom> {
if (file.size !== CRYSTAL_ROM.size) {
throw new Error(
"Choose an uncompressed Pokémon Crystal English US/Europe 1.1 ROM (.gbc, 2 MiB).",
);
}
if (!globalThis.crypto?.subtle) {
throw new Error(
"ROM verification needs HTTPS or localhost. Open this player over a secure connection.",
);
}
const buffer = await file.arrayBuffer();
const digest = await crypto.subtle.digest("SHA-1", buffer);
const sha1 = Array.from(new Uint8Array(digest), (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
if (sha1 !== CRYSTAL_ROM.sha1) {
throw new Error(
"This ROM is not the supported English US/Europe revision 1.1. Other languages, revisions, and modified ROMs are not supported yet.",
);
}
return { bytes: new Uint8Array(buffer), sha1, name: CRYSTAL_ROM.name };
}
+90
View File
@@ -0,0 +1,90 @@
export interface SaveRecord {
version: 1;
romHash: string;
/** Historical envelope field. Native saves put their runtime/schema ID here. */
coreId: string;
savedAt: string;
state: Uint8Array;
}
export interface SaveRepository {
read(romHash: string): Promise<SaveRecord | undefined>;
write(record: SaveRecord): Promise<void>;
}
export class BrowserSaveRepository implements SaveRepository {
private database?: Promise<IDBDatabase>;
constructor(private databaseName = "crystal-player") {}
private open() {
return (this.database ??= new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(this.databaseName, 1);
request.onupgradeneeded = () =>
request.result.createObjectStore("saves", { keyPath: "romHash" });
request.onsuccess = () => resolve(request.result);
request.onerror = () => {
this.database = undefined;
reject(request.error);
};
request.onblocked = () =>
reject(
new Error("Close other Crystal tabs, then reload to enable saves."),
);
}));
}
async read(romHash: string) {
const db = await this.open();
return new Promise<SaveRecord | undefined>((resolve, reject) => {
const request = db.transaction("saves").objectStore("saves").get(romHash);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async write(record: SaveRecord) {
const db = await this.open();
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction("saves", "readwrite");
transaction.objectStore("saves").put(record);
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () =>
reject(transaction.error ?? new Error("Save was interrupted."));
});
}
}
export function encodeSave(record: SaveRecord): string {
let binary = "";
for (const byte of record.state) binary += String.fromCharCode(byte);
return JSON.stringify({ ...record, state: btoa(binary) });
}
export function decodeSave(
text: string,
romHash: string,
coreId: string,
): SaveRecord {
if (text.length > 2_000_000) throw new Error("The save file is too large.");
const value = JSON.parse(text);
if (
!value ||
value.version !== 1 ||
value.romHash !== romHash ||
value.coreId !== coreId ||
typeof value.savedAt !== "string" ||
!Number.isFinite(Date.parse(value.savedAt)) ||
typeof value.state !== "string"
) {
throw new Error(
"This backup is not compatible with this ROM and player version.",
);
}
if (
!/^[A-Za-z0-9+/]+={0,2}$/.test(value.state) ||
value.state.length % 4 !== 0
)
throw new Error("The backup is damaged.");
const state = Uint8Array.from(atob(value.state), (char) =>
char.charCodeAt(0),
);
return { version: 1, romHash, coreId, savedAt: value.savedAt, state };
}
+55
View File
@@ -0,0 +1,55 @@
const key = (name: string, label: string, className = "") =>
`<button class="game-key ${className}" data-game-button="${name}" aria-label="${name === "a" || name === "b" ? name.toUpperCase() : name}" aria-pressed="false" disabled>${label}</button>`;
export function renderShell(root: HTMLElement) {
root.innerHTML = `
<header class="site-header">
<a class="brand" href="./" aria-label="Crystal home"><img src="${import.meta.env.BASE_URL}icon.svg" alt="" width="36" height="36">crystal<span>NATIVE WEB PORT</span></a>
<button id="help" class="text-button">How to play <span aria-hidden="true">↗</span></button>
</header>
<main>
<section class="intro" aria-labelledby="page-title">
<p class="eyebrow"><span class="small-star">✧</span> YOUR CARTRIDGE. A NEW HOME.</p>
<h1 id="page-title">A little adventure.<br><em>Anywhere.</em></h1>
<p class="intro-copy">Crystal, rebuilt for the web.<br> Start exploring New Bark Town in the first native milestone.</p>
<div class="load-card">
<div class="card-heading"><span class="step-number">01</span><h2>Bring your cartridge</h2></div>
<p>Pokémon Crystal<br><strong>English US/Europe · Revision 1.1</strong></p>
<div class="player-setup"><label>Name <input id="player-name" value="CHRIS" maxlength="7" pattern="[A-Za-z0-9 -]{1,7}" autocomplete="off"></label><label>Character <select id="player-avatar"><option value="chris">Chris</option><option value="kris">Kris</option></select></label><small>Used for your first session. Saved sessions keep their character.</small></div>
<label class="primary-button file-button" id="rom-label" for="rom-file"><span aria-hidden="true">+</span> Choose ROM <span aria-hidden="true">→</span></label>
<input id="rom-file" class="file-input" type="file" accept=".gbc,.gb,.rom,application/octet-stream" />
<p class="privacy-note"><span aria-hidden="true">◇</span> Opened locally. Never uploaded.</p>
</div>
<div class="project-note"><span class="note-line"></span><p>An exploration build, not the full game yet.<br><span>Move, enter buildings, read signs and try interactions. Story progression, battles and audio are next.</span></p></div>
</section>
<section class="play-area" aria-label="Game player">
<div class="console">
<div class="console-top"><span>NATIVE / EXPLORATION</span><span class="power"><i id="power-light"></i><span id="power-label">STANDBY</span></span></div>
<div class="screen-bezel">
<div class="screen">
<canvas id="game-screen" width="160" height="144" tabindex="0" aria-label="Game screen. Arrow keys move, X is A, Z is B, Enter is Start, Shift is Select."></canvas>
<div id="native-dialogue" class="native-dialogue" aria-label="Dialogue" hidden><p id="dialogue-text" aria-live="polite"></p><div><span id="dialogue-page"></span><button id="dialogue-next">Continue <span aria-hidden="true">▸</span></button></div></div>
<div id="screen-empty" class="screen-empty"><div class="crystal-mark" aria-hidden="true">◇</div><h2>Your next chapter<br>starts here.</h2><p>Load your Crystal ROM to begin.</p><span class="screen-dots" aria-hidden="true">· · ·</span></div>
<div id="pause-overlay" class="pause-overlay" hidden><span>PAUSED</span><button id="screen-resume">Continue playing →</button></div>
</div>
<div class="screen-caption"><span id="location">YOUR WORLD, REBUILT</span><span>160 × 144</span></div>
</div>
<div class="controls">
<div class="dpad" aria-label="Directional controls">${key("up", "▲")}${key("left", "◀")}<span class="dpad-center" aria-hidden="true"></span>${key("right", "▶")}${key("down", "▼")}</div>
<div class="action-keys">${key("b", "B")}${key("a", "A")}</div>
<div class="system-keys">${key("select", "SELECT")}${key("start", "START")}</div>
<div class="speaker" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
</div>
<div class="console-bottom"><span>MADE FOR THE WAY YOU PLAY</span><span>✧</span></div>
</div>
<div class="player-toolbar" aria-label="Player actions"><button id="play" disabled>Play</button><button id="save" disabled>Save session</button><button id="backup" disabled>Export backup</button></div>
<p class="world-status"><span id="tiles-walked">0 tiles walked</span><span>Exploration build · Audio pending</span></p>
<p id="game-notice" role="status" class="game-notice"></p>
<p id="status" class="status" role="status" aria-live="polite">Ready when you are. Choose your ROM to get started.</p>
<p id="save-status" class="save-status">Session backups stay in this browser.</p>
<details class="save-tools"><summary>Save files & compatibility</summary><div><button id="import-save" disabled>Import native backup</button><input id="save-file" class="file-input" type="file" accept=".json" /><p>Native saves keep your character, position and completed interactions. They are separate from emulator snapshots and cartridge .sav files, which cannot be imported yet.</p><p>Time-of-day colors follow your device clock. The tile counter measures in-game movement, not real-world steps. Export a backup before clearing browser data.</p></div></details>
</section>
</main>
<footer><span>A personal cartridge, a pocket-sized world.</span><span>Independent project · No game assets included</span></footer>
<dialog id="help-dialog"><button id="close-help" class="dialog-close" aria-label="Close help">×</button><p class="eyebrow">NATIVE PORT · FIRST MILESTONE</p><h2>Make yourself at home.</h2><p>Choose your own English US/Europe revision 1.1 ROM, then press Play. Graphics, maps, sprites and dialogue are read locally from your ROM. Select it again after a reload to resume.</p><p>Explore your bedroom, downstairs, New Bark Town and its buildings. Use A to read signs, the radio and supported conversations. An unfinished interaction shows a message and leaves progress unchanged.</p><dl><dt>Move</dt><dd>Arrow keys</dd><dt>Interact / Continue</dt><dd>X / Z or A / B</dd></dl><p>Sessions save every 10 seconds and when paused. Download a backup before clearing browser data. Switching apps pauses play.</p><p>This is an exploration milestone. Opening scenes, story progression, battles, NPC movement, room decorations, audio and routes beyond town are not ported yet. Online Link and real-world steps come after the original game systems.</p></dialog>`;
}
+931
View File
@@ -0,0 +1,931 @@
:root {
font-family:
Inter,
ui-sans-serif,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
color: #183d36;
background: #f4f4ec;
font-synthesis: none;
font-weight: 400;
font-size: 15px;
line-height: 1.5;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
.player-setup {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin: 12px 0;
grid-column: 1 / -1;
}
.player-setup label {
font-size: 10px;
color: #647962;
}
.player-setup input,
.player-setup select {
display: block;
width: 100%;
min-width: 0;
min-height: 44px;
border: 1px solid #d5ddd1;
border-radius: 6px;
background: #fff;
padding: 8px;
color: #234e40;
font: inherit;
font-size: 12px;
}
.player-setup small {
grid-column: 1 / -1;
font-size: 9px;
color: #7a8b7b;
}
.world-status {
display: flex;
justify-content: space-between;
font-size: 9px;
color: #73816f;
gap: 8px;
}
.game-notice {
color: #765b38;
font-size: 11px;
text-align: center;
margin: 0;
}
.game-notice:empty {
display: none;
}
.native-dialogue {
position: absolute;
bottom: 8px;
left: 8px;
right: 8px;
z-index: 1;
padding: 12px;
color: #17382b;
background: #fffdf0;
border: 2px solid #284c3c;
border-radius: 8px;
box-shadow: 0 3px 15px #102b2a55;
}
.native-dialogue p {
font-size: clamp(11px, 2.8vw, 15px);
line-height: 1.45;
white-space: pre-line;
margin: 0;
max-height: 125px;
overflow: auto;
}
.native-dialogue > div {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 9px;
}
.native-dialogue button {
background: transparent;
padding: 8px 0 0 12px;
font-size: 11px;
}
.pause-overlay {
z-index: 2;
}
body {
margin: 0;
padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left);
}
button,
input {
font: inherit;
}
button,
a,
label {
-webkit-tap-highlight-color: transparent;
}
button {
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.42;
}
button:focus-visible,
a:focus-visible,
summary:focus-visible,
canvas:focus-visible {
outline: 3px solid #ca713a;
outline-offset: 4px;
}
button {
color: inherit;
}
button:disabled {
pointer-events: none;
}
a {
color: inherit;
}
button,
.file-button {
min-height: 44px;
}
button {
border: 0;
}
button:hover:not(:disabled) {
filter: brightness(0.94);
}
[hidden] {
display: none !important;
}
.site-header {
max-width: 1280px;
margin: auto;
padding: 30px 54px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid #dce1d6;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-size: 28px;
letter-spacing: -1.5px;
font-weight: 750;
text-decoration: none;
}
.brand span {
font-size: 9px;
letter-spacing: 1.7px;
font-weight: 600;
margin-left: 13px;
padding-left: 20px;
border-left: 1px solid #c8d0c6;
}
.text-button {
background: none;
font-size: 12px;
}
.text-button span {
margin-left: 16px;
}
main {
max-width: 1180px;
margin: 0 auto;
display: grid;
grid-template-columns: 1fr 490px;
gap: 85px;
padding: 62px 40px 45px;
}
.intro {
padding-top: 24px;
}
.eyebrow {
font-size: 10px;
letter-spacing: 2px;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
}
.small-star {
font-size: 23px;
color: #6d8d72;
}
h1 {
font-size: clamp(38px, 4.8vw, 62px);
line-height: 1.08;
letter-spacing: -3px;
font-weight: 500;
margin: 26px 0;
}
h1 em {
font-family: Georgia, serif;
font-weight: 400;
color: #759378;
}
.intro-copy {
font-size: 15px;
line-height: 1.85;
color: #6a7b6e;
margin-bottom: 30px;
}
.load-card {
background: #fbfcf7;
border: 1px solid #dce2d6;
border-radius: 15px;
padding: 24px;
max-width: 350px;
box-shadow: 0 5px 18px #20352a03;
}
.card-heading {
display: flex;
gap: 12px;
align-items: center;
}
.step-number {
font-size: 10px;
color: #738876;
border: 1px solid #d9e1d5;
border-radius: 50%;
height: 26px;
width: 26px;
display: grid;
place-items: center;
}
.load-card h2 {
font-size: 14px;
margin: 0;
font-weight: 600;
}
.load-card > p {
color: #73816f;
font-size: 12px;
margin: 18px 0;
}
.load-card strong {
font-weight: 500;
color: #2e5246;
}
.primary-button {
background: #234e40;
color: #fff;
border: 1px solid #234e40;
border-radius: 7px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 15px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.file-input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.load-card:focus-within .file-button {
outline: 3px solid #ca713a;
outline-offset: 3px;
}
.disabled {
opacity: 0.5;
pointer-events: none;
}
.load-card .privacy-note {
font-size: 10px;
margin: 13px 0 0;
text-align: center;
color: #7a8b7b;
}
.privacy-note span {
margin-right: 4px;
}
.project-note {
display: flex;
gap: 12px;
margin-top: 30px;
align-items: center;
max-width: 350px;
}
.note-line {
width: 3px;
align-self: stretch;
background: #d4ddce;
margin: 12px 0;
}
.project-note p {
font-size: 11px;
color: #576d5b;
line-height: 1.8;
}
.project-note p span {
color: #8b9789;
}
.console {
background: #133532;
border-radius: 25px 25px 60px 25px;
padding: 22px 25px 18px;
box-shadow:
0 23px 45px #1335321c,
inset 0 1px 1px #ffffff28;
color: #a7c6b8;
border: 1px solid #244b42;
}
.console-top {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 9px;
letter-spacing: 2px;
padding: 0 3px 17px;
}
.power {
display: flex;
align-items: center;
gap: 6px;
font-size: 8px;
letter-spacing: 1px;
}
.power i {
height: 5px;
width: 5px;
border-radius: 50%;
background: #6f887a;
}
.power i.on {
background: #b2e6a5;
box-shadow: 0 0 7px #b2e6a5;
}
.screen-bezel {
padding: 15px 16px 9px;
background: #0a2423;
border-radius: 9px 9px 25px 9px;
border: 1px solid #315249;
}
.screen {
position: relative;
aspect-ratio: 160/144;
background: #dae6c7;
overflow: hidden;
border-radius: 3px;
}
canvas {
display: block;
width: 100%;
height: 100%;
image-rendering: pixelated;
}
.screen-empty {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: #526c49;
background: radial-gradient(ellipse at center, #e6ecd6, #ccdcbc);
}
.crystal-mark {
font-size: 84px;
line-height: 1.05;
font-family: Georgia, serif;
font-weight: 400;
color: #64885d;
text-shadow: 6px 0 #b6cbaa;
margin-top: -8px;
}
.screen-empty h2 {
font-family: Georgia, serif;
font-size: 25px;
line-height: 1.2;
font-weight: 400;
letter-spacing: -0.5px;
margin: 16px 0 10px;
}
.screen-empty p {
font-size: 10px;
margin: 0;
color: #718565;
}
.screen-dots {
margin-top: 12px;
font-size: 20px;
letter-spacing: 5px;
color: #8da17b;
}
.screen-caption {
display: flex;
justify-content: space-between;
padding: 9px 0 0;
letter-spacing: 2px;
font-size: 7px;
color: #6f8e7f;
}
.controls {
display: grid;
grid-template-columns: 1fr 1fr;
align-items: center;
padding: 22px 8px 4px;
gap: 15px;
}
.dpad {
display: grid;
grid-template-columns: repeat(3, 44px);
grid-template-rows: repeat(3, 44px);
filter: drop-shadow(0 5px 1px #061918);
}
.game-key {
min-width: 44px;
flex-shrink: 0;
touch-action: none;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.dpad .game-key,
.dpad-center {
background: #254d43;
box-shadow: inset 0 1px #527363;
color: #8fb09a;
min-height: 44px;
font-size: 14px;
}
.dpad .game-key {
border-radius: 4px;
}
.dpad [data-game-button="up"] {
grid-column: 2;
grid-row: 1;
}
.dpad [data-game-button="left"] {
grid-column: 1;
grid-row: 2;
}
.dpad-center {
grid-column: 2;
grid-row: 2;
box-shadow: none;
}
.dpad-center:after {
content: "";
display: block;
width: 14px;
height: 14px;
border: 1px solid #365e50;
border-radius: 50%;
margin: 15px;
}
.dpad [data-game-button="right"] {
grid-column: 3;
grid-row: 2;
}
.dpad [data-game-button="down"] {
grid-column: 2;
grid-row: 3;
}
.action-keys {
display: flex;
gap: 17px;
justify-content: flex-end;
transform: rotate(-20deg);
}
.action-keys button {
width: 59px;
height: 59px;
border-radius: 50%;
background: #d8e7c0;
color: #35573e;
font-size: 20px;
font-weight: 650;
border: 3px solid #b3caa0;
box-shadow: 0 5px #081f1c;
transform: rotate(20deg);
}
.game-key:disabled {
opacity: 0.7;
}
.game-key.pressed {
filter: brightness(1.3);
translate: 0 3px;
box-shadow: none;
}
.system-keys {
display: flex;
gap: 15px;
grid-column: 1/3;
justify-content: center;
margin-top: -4px;
}
.system-keys button {
background: none;
position: relative;
font-size: 8px;
letter-spacing: 1px;
padding-top: 22px;
color: #86a696;
}
.system-keys button:before {
content: "";
position: absolute;
top: 6px;
left: 50%;
transform: translateX(-50%) rotate(-10deg);
width: 33px;
height: 9px;
background: #416356;
border: 1px solid #517463;
border-radius: 7px;
box-shadow: 0 3px #071f1c;
}
.speaker {
display: none;
}
.console-bottom {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 7px;
letter-spacing: 1.7px;
color: #54776a;
margin: 13px 5px 0;
}
.console-bottom > span:last-child {
font-size: 18px;
}
.player-toolbar {
display: flex;
justify-content: center;
gap: 5px;
padding-top: 20px;
flex-wrap: wrap;
}
.player-toolbar button,
.save-tools button {
background: transparent;
font-size: 11px;
padding: 8px 11px;
border: 1px solid #d5ddd1;
border-radius: 7px;
}
.status {
font-size: 11px;
text-align: center;
color: #647962;
min-height: 17px;
margin: 14px 0 0;
overflow-wrap: anywhere;
}
.save-status {
text-align: center;
font-size: 10px;
color: #8b9788;
margin: 6px 0;
}
.error {
color: #a03e29 !important;
}
.save-tools {
font-size: 11px;
color: #71816c;
text-align: center;
margin-top: 16px;
}
.save-tools summary {
cursor: pointer;
min-height: 44px;
padding: 12px;
}
.save-tools > div {
padding: 12px;
border: 1px solid #d5ddd1;
border-radius: 8px;
text-align: left;
}
.save-tools button {
margin: 0 5px 5px 0;
}
.save-tools p {
font-size: 11px;
line-height: 1.7;
}
.pause-overlay {
position: absolute;
inset: 0;
background: #0a2423b8;
display: flex;
flex-direction: column;
gap: 20px;
align-items: center;
justify-content: center;
color: #e7efdb;
backdrop-filter: blur(2px);
}
.pause-overlay span {
font-size: 10px;
letter-spacing: 4px;
}
.pause-overlay button {
background: #d8e7c0;
color: #21483b;
border-radius: 7px;
padding: 10px 18px;
font-size: 12px;
}
footer {
max-width: 1172px;
margin: auto;
border-top: 1px solid #dce1d6;
padding: 22px 0 28px;
display: flex;
justify-content: space-between;
gap: 15px;
color: #8a9886;
font-size: 10px;
}
dialog {
border: 1px solid #d2ddca;
background: #f4f4ec;
color: #244c3e;
border-radius: 18px;
padding: 32px;
max-width: 460px;
width: calc(100% - 32px);
}
dialog::backdrop {
background: #0a242399;
backdrop-filter: blur(3px);
}
dialog h2 {
font-family: Georgia, serif;
font-weight: 400;
font-size: 30px;
}
dialog p {
font-size: 13px;
line-height: 1.7;
}
dialog dl {
display: grid;
grid-template-columns: 1fr 1fr;
font-size: 13px;
gap: 10px;
}
dialog dd {
margin: 0;
}
.dialog-close {
float: right;
background: none;
font-size: 25px;
}
@media (min-width: 1300px) {
main {
gap: 100px;
padding-top: 75px;
padding-bottom: 60px;
}
.intro {
padding-top: 37px;
}
}
@media (max-width: 1000px) {
main {
gap: 35px;
grid-template-columns: minmax(0, 1fr) minmax(340px, 440px);
padding: 40px 30px;
}
.site-header {
padding: 22px 30px;
}
h1 {
font-size: 45px;
}
.console {
padding: 20px;
}
.screen-empty h2 {
font-size: 23px;
}
footer {
margin: 0 30px;
}
}
@media (max-width: 740px) {
.site-header {
padding: 17px 22px;
}
.brand {
font-size: 25px;
}
.brand img {
width: 30px;
height: 30px;
}
.brand span {
display: none;
}
main {
display: flex;
flex-direction: column;
gap: 25px;
padding: 20px 18px 26px;
max-width: 480px;
}
.intro {
padding: 0;
}
.eyebrow {
font-size: 8px;
letter-spacing: 1.6px;
margin: 10px 0;
}
h1 {
font-size: 36px;
letter-spacing: -1.6px;
margin: 15px 0 13px;
}
h1 br {
display: block;
}
h1 em {
white-space: nowrap;
}
.intro-copy {
font-size: 12px;
line-height: 1.7;
margin: 0 0 18px;
}
.intro-copy br {
display: none;
}
.intro-copy br:after {
content: " ";
}
#app.has-game .intro {
display: none;
}
#app.has-game main {
padding-top: 12px;
}
.load-card {
max-width: none;
padding: 15px 18px;
display: grid;
grid-template-columns: 1fr 140px;
column-gap: 10px;
align-items: center;
}
.card-heading h2 {
font-size: 12px;
}
.step-number {
display: none;
}
.load-card > p {
margin: 5px 0;
font-size: 10px;
grid-column: 1;
}
.load-card .primary-button {
grid-column: 2;
grid-row: 1/3;
font-size: 11px;
padding: 10px;
gap: 5px;
}
.load-card .privacy-note {
grid-column: 1/3;
margin: 10px 0 0;
font-size: 9px;
}
.project-note {
display: none;
}
.console {
border-radius: 22px 22px 50px 22px;
padding: 20px;
}
.console-top {
font-size: 8px;
}
.screen-bezel {
padding: 12px 12px 9px;
}
.controls {
padding: 22px 4px 0;
}
.action-keys button {
width: 55px;
height: 55px;
}
.action-keys {
gap: 14px;
}
.player-toolbar {
padding-top: 17px;
}
.player-toolbar button {
font-size: 10px;
padding: 8px 10px;
}
.status {
font-size: 10px;
}
footer {
padding: 18px 0 25px;
margin: 0 22px;
flex-direction: column;
font-size: 9px;
gap: 5px;
}
}
@media (max-width: 360px) {
.load-card {
grid-template-columns: 1fr 114px;
padding: 13px;
}
.load-card .primary-button {
font-size: 10px;
}
.console {
padding: 15px;
}
.screen-bezel {
padding: 9px;
}
.screen-empty h2 {
font-size: 21px;
}
.screen-empty .crystal-mark {
font-size: 62px;
}
.action-keys {
gap: 8px;
}
.action-keys button {
width: 48px;
height: 48px;
}
.dpad {
grid-template-columns: repeat(3, 44px);
grid-template-rows: repeat(3, 44px);
}
.controls {
padding-left: 0;
padding-right: 0;
gap: 8px;
}
.system-keys {
margin-top: 0;
}
}
@media (max-height: 540px) and (orientation: landscape) {
#app.has-game
:is(
.site-header,
.intro,
footer,
.console-top,
.console-bottom,
.screen-caption,
.save-tools,
.save-status
) {
display: none;
}
#app.has-game main {
display: block;
max-width: 850px;
padding: 12px 20px;
}
#app.has-game .console {
position: relative;
padding: 12px;
border-radius: 20px;
min-height: 270px;
}
#app.has-game .screen-bezel {
width: min(43%, 340px);
margin: auto;
padding: 8px;
}
#app.has-game .controls {
position: absolute;
inset: 0;
display: flex;
justify-content: space-between;
pointer-events: none;
padding: 25px;
}
#app.has-game .controls > * {
pointer-events: auto;
}
#app.has-game .system-keys {
position: absolute;
bottom: 18px;
left: 30px;
}
#app.has-game .player-toolbar {
padding-top: 8px;
}
#app.has-game .status {
margin-top: 6px;
}
}
+236
View File
@@ -0,0 +1,236 @@
import { expect, test } from "@playwright/test";
import { syntheticRom } from "./synthetic-rom";
test("ROM gate, help and responsive layouts", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
await expect(
page.getByRole("heading", { name: "A little adventure. Anywhere." }),
).toBeVisible();
await expect(page.locator("#play")).toBeDisabled();
await page.locator("#rom-file").setInputFiles({
name: "wrong.gbc",
mimeType: "application/octet-stream",
buffer: Buffer.from("wrong"),
});
await expect(page.locator("#status")).toContainText("2 MiB");
await page.locator("#help").click();
await expect(page.locator("#help-dialog")).toBeVisible();
await page.locator("#close-help").click();
for (const [width, height] of [
[1440, 1000],
[1024, 900],
[768, 1024],
[390, 844],
[320, 740],
[844, 390],
]) {
await page.setViewportSize({ width, height });
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
const buttons = await page
.locator("[data-game-button]")
.evaluateAll((elements) =>
elements.map((el) => {
const rect = el.getBoundingClientRect();
return { width: rect.width, height: rect.height };
}),
);
for (const button of buttons) {
expect(button.width).toBeGreaterThanOrEqual(44);
expect(button.height).toBeGreaterThanOrEqual(44);
}
}
expect(errors).toEqual([]);
});
test("actual WASM executes an original cartridge, reads input and round-trips state", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
const result = await page.evaluate(async (bytes) => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const canvas = document.createElement("canvas");
document.body.append(canvas);
const emulator = new BinjgbEmulator((error) => {
throw error;
});
const wait = () => new Promise((resolve) => setTimeout(resolve, 180));
await emulator.load(new Uint8Array(bytes), canvas);
emulator.play();
await wait();
emulator.pause();
const before = emulator.exportBattery();
const state = emulator.captureState();
const pixels = canvas.getContext("2d")!.getImageData(0, 0, 160, 144).data;
const colors = new Set<string>();
for (let i = 0; i < pixels.length; i += 4)
colors.add(`${pixels[i]},${pixels[i + 1]},${pixels[i + 2]}`);
emulator.setButton("a", true);
emulator.play();
await wait();
emulator.pause();
const pressed = emulator.exportBattery()[0];
emulator.restoreState(state);
const restored = emulator.exportBattery()[0];
const badSizeRejected = (() => {
try {
emulator.restoreState(new Uint8Array([1]));
return false;
} catch {
return true;
}
})();
emulator.dispose();
await emulator.load(new Uint8Array(bytes), canvas);
emulator.restoreState(state);
const reopened = emulator.exportBattery()[0];
emulator.dispose();
canvas.remove();
return {
unpressed: before[0],
pressed,
restored,
reopened,
colors: colors.size,
stateSize: state.length,
ramSize: before.length,
badSizeRejected,
};
}, Array.from(syntheticRom()));
expect(result.ramSize).toBe(32768);
expect(result.stateSize).toBeGreaterThan(32768);
expect(result.colors).toBeGreaterThan(1);
expect(result.unpressed & 1).toBe(1);
expect(result.pressed & 1).toBe(0);
expect(result.restored).toBe(result.unpressed);
expect(result.reopened).toBe(result.unpressed);
expect(result.badSizeRejected).toBe(true);
expect(errors).toEqual([]);
});
test("full player lifecycle with test-only validator replacement", async ({
page,
context,
browserName,
}) => {
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
// The shipped validator is never relaxed. Only this browser's module response
// is replaced to exercise the real UI with our original synthetic cartridge.
await page.route("**/src/rom/validate.ts", (route) =>
route.fulfill({
contentType: "application/javascript",
body: 'export async function validateRom(file) { return { bytes: new Uint8Array(await file.arrayBuffer()), sha1: "synthetic-browser-test", name: "Original test cartridge" }; }',
}),
);
await page.goto("/reference.html");
const cartridge = {
name: "synthetic.gbc",
mimeType: "application/octet-stream",
buffer: Buffer.from(syntheticRom()),
};
await page.locator("#rom-file").setInputFiles(cartridge);
await expect(page.locator("#status")).toContainText("verified");
await page.locator("#play").click();
await expect(page.locator("#power-label")).toHaveText("PLAYING");
const action = page.locator('[data-game-button="a"]');
await action.scrollIntoViewIfNeeded();
const bounds = (await action.boundingBox())!;
await page.mouse.move(
bounds.x + bounds.width / 2,
bounds.y + bounds.height / 2,
);
await page.mouse.down();
await expect(action).toHaveAttribute("aria-pressed", "true");
await page.mouse.up();
await expect(action).toHaveAttribute("aria-pressed", "false");
await page.locator("#game-screen").focus();
await page.keyboard.down("KeyX");
await expect(page.locator('[data-game-button="a"]')).toHaveAttribute(
"aria-pressed",
"true",
);
await page.keyboard.up("KeyX");
await action.focus();
await page.keyboard.down("Space");
await expect(action).toHaveAttribute("aria-pressed", "true");
await page.keyboard.up("Space");
await expect(action).toHaveAttribute("aria-pressed", "false");
// Loaded layouts must keep screen and gameplay controls visible together.
for (const [width, height] of [
[390, 844],
[320, 740],
[844, 390],
]) {
await page.setViewportSize({ width, height });
await page.evaluate(() => scrollTo(0, 0));
const geometry = await page.locator(".console").evaluate((el) => {
const rect = el.getBoundingClientRect();
return {
top: rect.top,
bottom: rect.bottom,
viewport: innerHeight,
fits: document.documentElement.scrollWidth <= innerWidth,
};
});
expect(geometry.fits).toBe(true);
expect(geometry.top).toBeGreaterThanOrEqual(0);
expect(geometry.bottom).toBeLessThanOrEqual(geometry.viewport);
}
await page.setViewportSize({ width: 390, height: 844 });
if (browserName === "chromium") {
const cdp = await context.newCDPSession(page);
const up = page.locator('[data-game-button="up"]');
const points = await Promise.all(
[up, action].map(async (button, id) => {
const rect = (await button.boundingBox())!;
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2, id };
}),
);
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: points,
});
await expect(up).toHaveAttribute("aria-pressed", "true");
await expect(action).toHaveAttribute("aria-pressed", "true");
await cdp.send("Input.dispatchTouchEvent", {
type: "touchCancel",
touchPoints: [],
});
await expect(up).toHaveAttribute("aria-pressed", "false");
await expect(action).toHaveAttribute("aria-pressed", "false");
await cdp.detach();
}
await page.locator("#play").click();
await expect(page.locator("#power-label")).toHaveText("PAUSED");
await expect(page.locator("#save-status")).toContainText("Session saved");
const downloadEvent = page.waitForEvent("download");
await page.locator("#backup").click();
const download = await downloadEvent;
expect(download.suggestedFilename()).toContain(".crystal-save.json");
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles((await download.path())!);
await expect(page.locator("#status")).toContainText("Backup restored");
await page.reload();
await page.locator("#rom-file").setInputFiles(cartridge);
await expect(page.locator("#status")).toContainText("saved session is ready");
const second = await context.newPage();
await second.route("**/src/rom/validate.ts", (route) =>
route.fulfill({
contentType: "application/javascript",
body: 'export async function validateRom(file) { return { bytes: new Uint8Array(await file.arrayBuffer()), sha1: "synthetic-browser-test", name: "Test" }; }',
}),
);
await second.goto("/reference.html");
await second.locator("#rom-file").setInputFiles(cartridge);
await expect(second.locator("#status")).toContainText("another tab");
await second.close();
expect(errors).toEqual([]);
});
+163
View File
@@ -0,0 +1,163 @@
import { describe, expect, it, vi } from "vitest";
import "fake-indexeddb/auto";
import { InputController } from "../src/input/controller";
import { CRYSTAL_ROM, validateRom } from "../src/rom/validate";
import {
BrowserSaveRepository,
decodeSave,
encodeSave,
type SaveRecord,
} from "../src/storage/saves";
import { GameSession } from "../src/app/session";
import type { Emulator } from "../src/emulator/types";
const record: SaveRecord = {
version: 1,
romHash: "test-rom",
coreId: "test-core",
savedAt: "2026-09-22T12:00:00.000Z",
state: new Uint8Array([0, 128, 255]),
};
describe("ROM validation", () => {
it("rejects incorrect sizes without reading the file", async () => {
const file = new File(["not a game"], "crystal.gbc");
await expect(validateRom(file)).rejects.toThrow("2 MiB");
});
it("rejects a correctly sized file with a forged title and revision", async () => {
const bytes = new Uint8Array(CRYSTAL_ROM.size);
bytes.set(new TextEncoder().encode("PM_CRYSTAL"), 0x134);
bytes[0x14c] = 1;
await expect(
validateRom(new File([bytes], "Pokemon Crystal EU 1.1.gbc")),
).rejects.toThrow("not the supported");
});
});
describe("input aggregation", () => {
it("keeps a button pressed until all sources release it", () => {
const send = vi.fn();
const input = new InputController(send);
input.press("finger:1", "a");
input.press("keyboard:x", "a");
input.release("finger:1");
expect(send.mock.calls).toEqual([["a", true]]);
input.release("keyboard:x");
expect(send.mock.calls).toEqual([
["a", true],
["a", false],
]);
});
it("slides a pointer to a different direction and releases everything on interruption", () => {
const send = vi.fn();
const input = new InputController(send);
input.press("finger:1", "up");
input.press("finger:1", "left");
expect(send.mock.calls).toEqual([
["up", true],
["up", false],
["left", true],
]);
input.releaseAll();
expect(input.isPressed("left")).toBe(false);
expect(send).toHaveBeenCalledWith("left", false);
});
});
describe("saves", () => {
it("round trips binary state and rejects incompatible imports", () => {
const text = encodeSave(record);
expect(decodeSave(text, record.romHash, record.coreId)).toEqual(record);
expect(() => decodeSave(text, "another-rom", record.coreId)).toThrow(
"not compatible",
);
expect(() => decodeSave(text, record.romHash, "another-core")).toThrow(
"not compatible",
);
expect(() =>
decodeSave(
JSON.stringify({ ...record, state: "???=" }),
record.romHash,
record.coreId,
),
).toThrow("damaged");
});
it("persists isolated ROM slots through a fresh repository", async () => {
const first = new BrowserSaveRepository();
await first.write(record);
const second = new BrowserSaveRepository();
expect(await second.read(record.romHash)).toEqual(record);
expect(await second.read("another-rom")).toBeUndefined();
});
});
function setup(saved?: SaveRecord) {
let bytes = new Uint8Array([3]);
const emulator: Emulator = {
id: record.coreId,
load: vi.fn(async () => {}),
play: vi.fn(),
pause: vi.fn(),
setButton: vi.fn(),
setMuted: vi.fn(),
unlockAudio: vi.fn(),
captureState: () => bytes.slice(),
restoreState: vi.fn((state) => {
if (state[0] === 255) throw new Error("Bad state");
bytes = new Uint8Array(state);
}),
exportBattery: () => bytes.slice(),
dispose: vi.fn(),
};
const saves = {
read: vi.fn(async () => saved),
write: vi.fn(async (_value: SaveRecord) => {}),
};
const session = new GameSession(emulator, saves);
const rom = {
bytes: new Uint8Array([1]),
sha1: record.romHash,
name: "Synthetic",
};
return { emulator, saves, session, rom };
}
describe("session lifecycle", () => {
it("loads a saved session paused and saves while playing without pausing", async () => {
const { emulator, saves, session, rom } = setup(record);
expect(await session.load(rom, {} as HTMLCanvasElement)).toBe(true);
expect(session.state).toBe("paused");
expect(emulator.restoreState).toHaveBeenCalledWith(record.state);
session.play();
await session.save();
expect(session.state).toBe("playing");
expect(saves.write).toHaveBeenCalledOnce();
session.pause();
expect(session.state).toBe("paused");
});
it("preserves saves from another core and refuses to overwrite them", async () => {
const { session, rom, saves } = setup({ ...record, coreId: "old" });
await expect(session.load(rom, {} as HTMLCanvasElement)).rejects.toThrow(
"migration",
);
expect(saves.write).not.toHaveBeenCalled();
expect(() => session.snapshot()).toThrow("Load a ROM");
});
it("rolls back a failed backup import", async () => {
const { session, rom } = setup();
await session.load(rom, {} as HTMLCanvasElement);
await expect(
session.importSave(
encodeSave({ ...record, state: new Uint8Array([255]) }),
),
).rejects.toThrow("Bad state");
expect(session.snapshot().state).toEqual(new Uint8Array([3]));
});
it("reports write failures and allows a later save to succeed", async () => {
const { session, rom, saves } = setup();
await session.load(rom, {} as HTMLCanvasElement);
saves.write.mockRejectedValueOnce(new Error("Quota exceeded"));
await expect(session.save()).rejects.toThrow("Quota exceeded");
await expect(session.save()).resolves.toEqual(expect.any(String));
});
});
+106
View File
@@ -0,0 +1,106 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
test("development server does not serve ROMs or the private reference checkout", async ({
request,
}) => {
for (const path of [
"/Pokemon%20-%20Crystal%20Version%20(UE)%20(V1.1)%20%5BC%5D%5B!%5D.gbc",
"/.reference/pokecrystal/README.md",
]) {
const response = await request.get(path);
expect([403, 404]).toContain(response.status());
}
});
test("native player loads the actual ROM without loading an emulator and persists movement", async ({
page,
context,
}, testInfo) => {
test.skip(
!existsSync(romPath),
"Requires a local user-supplied ROM; never downloaded or committed.",
);
const requests: string[] = [],
errors: string[] = [];
page.on("request", (request) => requests.push(request.url()));
page.on("pageerror", (error) => errors.push(error.message));
await page.goto("/");
await page.locator("#player-name").fill("KAWA");
await page.locator("#player-avatar").selectOption("kris");
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("verified");
await expect(page.locator("#game-screen")).toHaveAttribute(
"data-map",
"24:7",
);
await page.locator("#play").click();
await expect(page.locator("#game-screen")).toHaveAttribute("data-x", "3");
await page.keyboard.down("ArrowUp");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2");
await page.keyboard.up("ArrowUp");
await page.waitForTimeout(150);
await page.keyboard.press("KeyX");
await expect(page.locator("#native-dialogue")).toBeVisible();
await expect(page.locator("#dialogue-page")).toHaveText("1 / 4");
for (let i = 0; i < 4; i++) await page.locator("#dialogue-next").click();
await expect(page.locator("#native-dialogue")).toBeHidden();
await page.locator("#play").click();
await expect(page.locator("#save-status")).toContainText("Session saved");
const exported = page.waitForEvent("download");
await page.locator("#backup").click();
const backup = await exported;
page.once("dialog", (dialog) => dialog.accept());
await page.locator("#save-file").setInputFiles((await backup.path())!);
await expect(page.locator("#status")).toContainText("Backup restored");
await page.reload();
await page.locator("#rom-file").setInputFiles(romPath);
await expect(page.locator("#status")).toContainText("saved session");
await expect(page.locator("#game-screen")).toHaveAttribute("data-y", "2");
await page.locator("#play").click();
await page.keyboard.press("KeyX");
await expect(page.locator("#dialogue-page")).toHaveText("1 / 1");
await page.locator("#dialogue-next").click();
for (const [width, height] of [
[1440, 1000],
[1024, 900],
[390, 844],
[320, 740],
[844, 390],
]) {
await page.setViewportSize({ width, height });
await page.evaluate(() => scrollTo(0, 0));
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
if (width <= 390 || height <= 390) {
const box = (await page.locator(".console").boundingBox())!;
expect(box.y + box.height).toBeLessThanOrEqual(height);
}
}
await page.setViewportSize({ width: 390, height: 844 });
await page.screenshot({
path: testInfo.outputPath("native-local-rom.png"),
fullPage: true,
});
const other = await context.newPage();
await other.goto("/");
await other.locator("#rom-file").setInputFiles(romPath);
await expect(other.locator("#status")).toContainText("another tab");
await other.close();
expect(
requests.some((url) => /binjgb|\.wasm|\/src\/emulator\//.test(url)),
).toBe(false);
expect(requests.every((url) => new URL(url).hostname === "127.0.0.1")).toBe(
true,
);
expect(errors).toEqual([]);
});
+225
View File
@@ -0,0 +1,225 @@
import { describe, expect, it } from "vitest";
import { readFileSync, existsSync } from "node:fs";
import { createHash } from "node:crypto";
import { RomReader } from "../src/game/rom/reader";
import { decompress } from "../src/game/rom/lz";
import { CrystalAssets } from "../src/game/rom/assets";
import { GameWorld } from "../src/game/world/world";
import { NATIVE_MAPS } from "../src/game/rom/profile";
import { runScript } from "../src/game/scripts/interpreter";
import { drawTile } from "../src/game/rendering/pixels";
import { CRYSTAL_ROM } from "../src/rom/validate";
describe("native ROM formats", () => {
it("resolves fixed and banked pointers and rejects out-of-bounds reads", () => {
const reader = new RomReader(new Uint8Array(0xc000));
expect(reader.address(2, 0x4321)).toBe(0x8321);
expect(reader.address(2, 0x1234)).toBe(0x1234);
expect(() => reader.address(3, 0x4000)).toThrow();
expect(() => reader.word(0xbfff)).toThrow();
expect(() => reader.slice(-1, 1)).toThrow();
});
it("decodes all LZ commands, long runs, overlapping copies and termination", () => {
const decode = (input: number[]) => [
...decompress(new RomReader(Uint8Array.from(input)), 0),
];
expect(decode([0x02, 1, 2, 3, 0x22, 7, 0x43, 8, 9, 0x61, 0xff])).toEqual([
1, 2, 3, 7, 7, 7, 8, 9, 8, 9, 0, 0,
]);
expect(decode([0, 5, 0x83, 0x80, 0xff])).toEqual([5, 5, 5, 5, 5]);
expect(decode([0, 0b10010000, 0xa0, 0, 0, 0xff])).toEqual([144, 9]);
expect(decode([2, 1, 2, 3, 0xc2, 0, 2, 0xff])).toEqual([1, 2, 3, 3, 2, 1]);
expect(decode([0xec, 39, 0xff])).toEqual(Array(40).fill(0));
expect(() => decode([0x80, 0x80, 0xff])).toThrow("back-reference");
expect(() =>
decompress(new RomReader(new Uint8Array([0xec, 99, 0xff])), 0, 10),
).toThrow("oversized");
expect(() => decode([0x20, 4])).toThrow();
});
it("decodes planar pixels with transparency and flips", () => {
const pixels = new Uint8ClampedArray(8 * 8 * 4);
const tile = new Uint8Array(16);
tile[0] = 0x80;
tile[1] = 0x40;
const palette = [
[0, 0, 0],
[255, 0, 0],
[0, 255, 0],
[0, 0, 255],
] as const;
drawTile(pixels, 8, 8, tile, 0, 0, 0, palette, {
transparent: true,
flipX: true,
});
expect([...pixels.slice(7 * 4, 8 * 4)]).toEqual([255, 0, 0, 255]);
expect([...pixels.slice(6 * 4, 7 * 4)]).toEqual([0, 255, 0, 255]);
expect(pixels[3]).toBe(0);
});
it("aborts unknown script commands without committing earlier event writes", () => {
const reader = new RomReader(Uint8Array.from([0x33, 5, 0, 0x0e, 0, 0, 0]));
const state = {
events: new Set<number>(),
flags: new Set<number>(),
variables: {},
};
expect(() => runScript(reader, 0, state, "TEST")).toThrow("not ported");
expect(state.events.size).toBe(0);
});
it("bounds looping scripts", () => {
const reader = new RomReader(Uint8Array.from([0x03, 0, 0]));
expect(() =>
runScript(
reader,
0,
{ events: new Set(), flags: new Set(), variables: {} },
"TEST",
),
).toThrow("budget");
});
});
const romPath =
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc";
describe.skipIf(!existsSync(romPath))(
"native engine with local user ROM (never checked in)",
() => {
const load = () => {
const bytes = readFileSync(romPath);
expect(createHash("sha1").update(bytes).digest("hex")).toBe(
CRYSTAL_ROM.sha1,
);
return new CrystalAssets(new Uint8Array(bytes));
};
it("decodes every supported map, tileset, event table and sprite", () => {
const assets = load();
for (const id of Object.keys(NATIVE_MAPS)) {
const map = assets.map(id),
tileset = assets.tileset(map);
expect(map.blocks.length).toBe((map.width * map.height) / 4);
expect(tileset.graphics.length).toBeGreaterThanOrEqual(0x600);
expect(assets.palettes(map, 1).length).toBe(8);
for (const warp of map.warps) {
expect(warp.x).toBeLessThan(map.width);
expect(warp.y).toBeLessThan(map.height);
if (warp.targetMap in NATIVE_MAPS)
expect(
assets.map(warp.targetMap).warps[warp.targetWarp - 1],
).toBeDefined();
}
}
expect(assets.sprite(1)?.graphics.length).toBe(384);
expect(assets.sprite(0x60)?.graphics.length).toBe(384);
expect(assets.map("24:7").warps[0]).toEqual({
x: 7,
y: 0,
targetMap: "24:6",
targetWarp: 3,
});
});
it.skipIf(
!existsSync(".reference/pokecrystal/gfx/tilesets/players_room.2bpp"),
)(
"matches decompressed graphics against the locally rebuilt PRET reference",
() => {
const path = ".reference/pokecrystal/gfx/tilesets/players_room.2bpp";
const assets = load();
expect(assets.tileset(assets.map("24:7")).graphics).toEqual(
new Uint8Array(readFileSync(path)),
);
},
);
it("walks from the bedroom to downstairs and the town, with collision and return warps", () => {
const world = new GameWorld(
load(),
() => new Date("2026-09-22T12:00:00"),
);
expect(world.x).toBe(3);
expect(world.y).toBe(3);
expect(world.move("up")).toBe(true);
const steps = world.tilesWalked;
expect(world.move("up")).toBe(false); // Radio/furniture is solid.
expect([world.x, world.y, world.tilesWalked]).toEqual([3, 2, steps]);
expect(world.move("down")).toBe(true);
const routeTo = (targetX: number, targetY: number) => {
const directions = [
["up", 0, -1],
["down", 0, 1],
["left", -1, 0],
["right", 1, 0],
] as const;
const queue = [
{
x: world.x,
y: world.y,
path: [] as ("up" | "down" | "left" | "right")[],
},
];
const visited = new Set<string>();
for (let i = 0; i < queue.length; i++) {
const node = queue[i],
key = `${node.x},${node.y}`;
if (visited.has(key)) continue;
visited.add(key);
if (node.x === targetX && node.y === targetY) {
for (const direction of node.path)
expect(world.move(direction)).toBe(true);
return;
}
for (const [direction, dx, dy] of directions) {
const x = node.x + dx,
y = node.y + dy;
if (x < 0 || y < 0 || x >= world.map.width || y >= world.map.height)
continue;
if (
world.assets.permission(
world.assets.collision(world.map, x, y),
) !== 0
)
continue;
if (
world.objects().some((object) => object.x === x && object.y === y)
)
continue;
if (
world.map.warps.some((warp) => warp.x === x && warp.y === y) &&
(x !== targetX || y !== targetY)
)
continue;
queue.push({ x, y, path: [...node.path, direction] });
}
}
throw new Error(`No route to ${targetX},${targetY}`);
};
routeTo(7, 0);
expect(world.mapId).toBe("24:6");
routeTo(6, 7);
expect(world.mapId).toBe("24:4");
expect(world.tilesWalked).toBeGreaterThan(10);
const arrival = world.map.warps.find(
(warp) => warp.targetMap === "24:6",
)!;
routeTo(arrival.x, arrival.y);
expect(world.mapId).toBe("24:6");
const save = world.save();
expect(() => world.restore({ ...save, x: 127 })).toThrow("position");
expect(world.save()).toEqual(save);
const restored = new GameWorld(world.assets);
restored.restore(save);
expect(restored.save()).toEqual(save);
});
it("reads the bedroom radio through native event and text interpreters", () => {
const world = new GameWorld(load());
world.restore({ ...world.save(), x: 3, y: 2, direction: "up" });
const previousEvents = world.state.events.size;
world.interact();
expect(world.diagnostic).toBe("");
expect(world.dialogue?.pages.length).toBe(4);
expect(world.state.events.size).toBe(previousEvents);
while (world.dialogue) world.advanceDialogue();
expect(world.state.events.size).toBe(previousEvents + 1);
world.interact();
expect(world.dialogue?.pages.length).toBe(1);
});
},
);
+106
View File
@@ -0,0 +1,106 @@
import { test, expect } from "@playwright/test";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
const romPath = resolve(
process.env.CRYSTAL_ROM_PATH ??
"Pokemon - Crystal Version (UE) (V1.1) [C][!].gbc",
);
test("native decompression matches original GB assembly executing in the reference emulator", async ({
page,
}) => {
test.skip(!existsSync(romPath), "Requires the local user ROM.");
await page.goto("/reference.html");
// File chooser supplies bytes to an isolated test input, without an HTTP ROM endpoint.
await page.evaluate(() => {
const input = document.createElement("input");
input.type = "file";
input.id = "reference-fixture";
document.body.append(input);
});
await page.locator("#reference-fixture").setInputFiles(romPath);
const result = await page.evaluate(async () => {
const { BinjgbEmulator } = await import("/src/emulator/binjgb.ts");
const { CrystalAssets } = await import("/src/game/rom/assets.ts");
const { decompress } = await import("/src/game/rom/lz.ts");
const { SYMBOLS, NATIVE_MAPS } = await import("/src/game/rom/profile.ts");
const file = (
document.querySelector("#reference-fixture") as HTMLInputElement
).files![0];
const bytes = new Uint8Array(await file.arrayBuffer());
const canvas = document.createElement("canvas");
const reference = new BinjgbEmulator();
const assets = new CrystalAssets(bytes);
await reference.load(bytes, canvas);
const comparisons: {
map: string;
bytes: number;
equal: boolean;
returned: boolean;
}[] = [];
for (const id of Object.keys(NATIVE_MAPS)) {
const map = assets.map(id);
const pointer = assets.reader.far(SYMBOLS.tilesets + map.tileset * 15);
const bank = Math.floor(pointer / 0x4000),
address = 0x4000 + (pointer % 0x4000);
const native = decompress(assets.reader, pointer);
// Original test trampoline in WRAM: DI; SP=$c7fe; A=bank;
// HL=compressed ROM address; DE=$d000; CALL FarDecompress ($0b40);
// write a sentinel and loop. The referenced routine is the user's ROM.
const program = [
0xf3,
0x31,
0xfe,
0xc7,
0x3e,
bank,
0x21,
address & 255,
address >> 8,
0x11,
0,
0xd0,
0xcd,
0x40,
0x0b,
0x3e,
0x42,
0xea,
0xff,
0xc6,
0x18,
0xfe,
];
reference.writeMemory(0xff70, 6);
reference.writeMemory(0xff9d, 1);
reference.writeMemory(0xc6ff, 0);
program.forEach((value, index) =>
reference.writeMemory(0xc000 + index, value),
);
reference.setProgramCounter(0xc000);
reference.advanceFrames(30);
comparisons.push({
map: id,
bytes: native.length,
returned: reference.readMemory(0xc6ff) === 0x42,
equal: native.every(
(value, index) => reference.readMemory(0xd000 + index) === value,
),
});
}
reference.dispose();
return comparisons;
});
expect(result.length).toBe(6);
for (const comparison of result) {
expect(
comparison.returned,
`${comparison.map}: original routine returned`,
).toBe(true);
expect(
comparison.equal,
`${comparison.map}: ${comparison.bytes} decompressed bytes match`,
).toBe(true);
}
});
+83
View File
@@ -0,0 +1,83 @@
/** Original test cartridge: no Nintendo logo, boot ROM, graphics, or game code.
* A tiny CPU loop draws a striped tile and writes joypad reads to battery RAM.
* This intentionally bypasses the product ROM gate ONLY in the test harness.
*/
export function syntheticRom(): Uint8Array {
const rom = new Uint8Array(32768);
rom.set([0xc3, 0x50, 0x01], 0x100); // JP $0150
rom.set(new TextEncoder().encode("CRYSTALTEST"), 0x134);
rom[0x143] = 0x80; // CGB-capable
rom[0x147] = 0x10; // MBC3 + RTC + RAM + battery
rom[0x148] = 0;
rom[0x149] = 3; // 32 KiB RAM
const code = [
0xf3, // DI
0x31,
0xfe,
0xff, // LD SP,$fffe
0xaf,
0xe0,
0x40, // LCD off
0x3e,
0x0a,
0xea,
0,
0, // enable cartridge RAM
0x3e,
0x80,
0xe0,
0x68, // CGB background palette auto-increment
0x3e,
0xff,
0xe0,
0x69,
0x3e,
0x7f,
0xe0,
0x69, // white
0xaf,
0xe0,
0x69,
0xe0,
0x69, // black
0x21,
0,
0x80,
0x06,
0x10,
0x3e,
0xaa, // HL=$8000, B=16, A=$aa
0x22,
0x05,
0x20,
0xfc, // tile pattern
0x3e,
0xe4,
0xe0,
0x47, // DMG palette
0x3e,
0x91,
0xe0,
0x40, // LCD on, background tile 0
];
const loop = 0x150 + code.length;
code.push(
0x3e,
0x10,
0xe0,
0,
0xf0,
0,
0xea,
0,
0xa0,
0xc3,
loop & 255,
loop >> 8,
);
rom.set(code, 0x150);
let checksum = 0;
for (let i = 0x134; i <= 0x14c; i++) checksum = (checksum - rom[i] - 1) & 255;
rom[0x14d] = checksum;
return rom;
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["vite/client"],
"noUnusedLocals": true,
"noUnusedParameters": true
},
"include": ["src"]
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig } from "vite";
export default defineConfig(({ mode }) => ({
publicDir: mode === "reference" ? "reference/public" : "public",
server: {
host: "127.0.0.1",
// A user ROM may sit at the repository root. Never serve it or extracted
// assets and the private PRET checkout as static files.
fs: {
deny: [
"**/*.{gb,gbc,rom,sav}",
"**/*.crystal-save.json",
"**/.reference/**",
"**/artifacts/**",
".env",
".env.*",
"**/.git/**",
],
},
},
build:
mode === "reference"
? {
outDir: "dist-reference",
rollupOptions: { input: "reference.html" },
}
: { outDir: "dist" },
}));
+2
View File
@@ -0,0 +1,2 @@
import { defineConfig } from "vitest/config";
export default defineConfig({ test: { include: ["tests/**/*.test.ts"] } });