diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 0000000..44ad72d --- /dev/null +++ b/.github/workflows/checks.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c398225 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/README.md b/README.md index 9a21ada..dff6ce6 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,49 @@ # Crystal -Decompilation of *Pokemon Crystal version* by **PRET** : https://pret.github.io/pokecrystal/ - https://github.com/pret/pokecrystal \ No newline at end of file +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. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..b3bc422 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/docs/NATIVE_PORT.md b/docs/NATIVE_PORT.md new file mode 100644 index 0000000..fcdc23b --- /dev/null +++ b/docs/NATIVE_PORT.md @@ -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. diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..1d96485 --- /dev/null +++ b/docs/PLAN.md @@ -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. diff --git a/index.html b/index.html new file mode 100644 index 0000000..9e2388e --- /dev/null +++ b/index.html @@ -0,0 +1,21 @@ + + + + + + + + Crystal · Native web port + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..035350d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1573 @@ +{ + "name": "crystal-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "crystal-web", + "version": "0.1.0", + "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" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.9.tgz", + "integrity": "sha512-tNISae1QEf/vkb3xkRcjV5SEdzPE97We5IVaa2Z8jSszQPZ8U60B/YCYpw4QI7VidYsBtKavczXf+DyDs9WGxw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.9.tgz", + "integrity": "sha512-YC8YsI30o606GTZi0VyzYlsDKFP8W61i/QzayHDkLbNEz/IShqAmTa+hsJRj13xTHA0H+6fk4b2UmGn+Q/cMlg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.9.tgz", + "integrity": "sha512-IwhlH3qK5urrY8hZiEgGkHKEFN901p/p2bjxCxJlr4GyNnF7wYpUvK+Y43uaRYuC4hpfjzbR3SJC3arX1jGvmw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.9.tgz", + "integrity": "sha512-XxpJfVzFh+jilRxIXUqcfYAYcunIc/XEzIizsOL1fcJee5Sf7H3mH8WlLmfHfluz5amqR88QQo9izKtmMlavAw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.9.tgz", + "integrity": "sha512-kSfvhmgeWyfkbT3p/1s5vSgboogoah2zkm9fX2zjg2hHxSV7T4KhMWRUUaRk4OXNqoD3QAUeRqLcs1aZOK4U1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.9.tgz", + "integrity": "sha512-1RVzG17pxqbTfYLC352JlLt6kKLG+6Hr30n8DlIJqsnV5luUDd2Qdx9Ayw1Cabfyb1K9k0jXEZ7evxkRoT+uiw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.9.tgz", + "integrity": "sha512-BXqPvZ2drqVD+/Z8UpKwcs4Mp7grM+eGFku4CAEKrEtcbAsUpzREphK1sogCRZGreVPiMkiiBtw0n3TPteuqvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.9.tgz", + "integrity": "sha512-11vWvo8YDwLzukt27J3aYDWU+gg2P7J+ZOmiJ0hkF5BXZDW7pVya7r40MXDy6ya0i9KamoENSVKIugvJNgFXIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.9.tgz", + "integrity": "sha512-a1tijMkdwsIARtc0F39ApURROkf3NwqinI6TOiSSWCTR7dT96dffNvMUtDHnq64wKNTIZOIlzKrFvvFUznJiyw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.9.tgz", + "integrity": "sha512-x6SQNdAvv4c3hWqTMaWuawzMX9myaCs/yEmlGsxJzkdClnHW7FbrjQuSiRDhuSYzEYoEMhsaJy9qHG/XNemJPQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.9.tgz", + "integrity": "sha512-9s0AZ8BFK5/n7B/TBoa2yJE3gI3KURrbXcPBlsAsvjU4VeJKgE90y1YtNxyEUIcHPQkg6/yfF3qihUrcM/Kf0Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.9.tgz", + "integrity": "sha512-P7VWAmV+WdJluH7ovnRGoiv2i8To7GAZ+kGzfGup635cyL7SyYl3lSUaA3Gp5THf0n/Co5EyEqb2zbqq+nMOHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.9.tgz", + "integrity": "sha512-1qixtsE4BK8h+yS3BfmZ09UhA7O/N4IACva6YBr7EBvCJraByTuRcgOTaiA62Tm0vey3UcKXLOaoGHtYmNGEVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.9.tgz", + "integrity": "sha512-ok8IQjcEPs1AKZfuEUznVBrJw+gK4soq+bx8b1X2XoMqVClarc1q5JDmVtWXY1xfr6ZuHTAsPXHTgTrqKTZeww==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.9.tgz", + "integrity": "sha512-Ip2mXoU0hM0boq3Rf+ekuT653OROSo6aSYcPT1VHE4q52KvyxgFkQgrgb/IEsxOuvQ2fZZbs8khJAyCEPM24/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.2.tgz", + "integrity": "sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz", + "integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.1", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz", + "integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fake-indexeddb": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz", + "integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz", + "integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prettier": { + "version": "3.9.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.8.tgz", + "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/rolldown": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.9.tgz", + "integrity": "sha512-hx/Pv0N1haXRb11qkfnK5MXB/iqr7i0yjWQqmO9uHqZpBgQSqzc8UsSnEpalsh+j1I8qQ2CkXAkJC8Br3dKSlg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.150.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.9", + "@rolldown/binding-android-arm64": "1.2.9", + "@rolldown/binding-darwin-arm64": "1.2.9", + "@rolldown/binding-darwin-x64": "1.2.9", + "@rolldown/binding-freebsd-x64": "1.2.9", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.9", + "@rolldown/binding-linux-arm64-gnu": "1.2.9", + "@rolldown/binding-linux-arm64-musl": "1.2.9", + "@rolldown/binding-linux-ppc64-gnu": "1.2.9", + "@rolldown/binding-linux-s390x-gnu": "1.2.9", + "@rolldown/binding-linux-x64-gnu": "1.2.9", + "@rolldown/binding-linux-x64-musl": "1.2.9", + "@rolldown/binding-openharmony-arm64": "1.2.9", + "@rolldown/binding-win32-arm64-msvc": "1.2.9", + "@rolldown/binding-win32-x64-msvc": "1.2.9" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", + "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.6", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz", + "integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.1", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.1", + "@vitest/browser-preview": "5.0.1", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.1", + "@vitest/coverage-v8": "5.0.1", + "@vitest/ui": "5.0.1", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ca1184b --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..4d2c23b --- /dev/null +++ b/playwright.config.ts @@ -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, + }, +}); diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..e6c4ccd --- /dev/null +++ b/public/icon.svg @@ -0,0 +1 @@ + diff --git a/reference.html b/reference.html new file mode 100644 index 0000000..df0635b --- /dev/null +++ b/reference.html @@ -0,0 +1,16 @@ + + + + + + Crystal · Reference emulator + + + +
+ + + diff --git a/reference/public/icon.svg b/reference/public/icon.svg new file mode 100644 index 0000000..e6c4ccd --- /dev/null +++ b/reference/public/icon.svg @@ -0,0 +1 @@ + diff --git a/reference/public/vendor/binjgb/LICENSE b/reference/public/vendor/binjgb/LICENSE new file mode 100644 index 0000000..4c7fada --- /dev/null +++ b/reference/public/vendor/binjgb/LICENSE @@ -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. diff --git a/reference/public/vendor/binjgb/PROVENANCE.md b/reference/public/vendor/binjgb/PROVENANCE.md new file mode 100644 index 0000000..3f05281 --- /dev/null +++ b/reference/public/vendor/binjgb/PROVENANCE.md @@ -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 diff --git a/reference/public/vendor/binjgb/binjgb.js b/reference/public/vendor/binjgb/binjgb.js new file mode 100644 index 0000000..226bc2b --- /dev/null +++ b/reference/public/vendor/binjgb/binjgb.js @@ -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 UTF8Decoder=globalThis.TextDecoder&&new TextDecoder;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var printChar=(stream,curr)=>{var buffer=printCharBuffers[stream];if(curr===0||curr===10){(stream===1?out:err)(UTF8ArrayToString(buffer));buffer.length=0}else{buffer.push(curr)}};var _fd_write=(fd,iov,iovcnt,pnum)=>{var num=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;for(var j=0;j>2]=num;return 0};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}var _malloc,_file_data_delete,_free,_emulator_set_builtin_palette,_emulator_was_ext_ram_updated,_emulator_read_state,_emulator_write_state,_emulator_read_ext_ram,_emulator_write_ext_ram,_emulator_delete,_emulator_get_PC,_emulator_get_A,_emulator_get_BC,_emulator_get_DE,_emulator_get_HL,_emulator_get_F,_emulator_get_SP,_emulator_set_PC,_emulator_get_wram_ptr,_emulator_get_hram_ptr,_emulator_read_mem,_emulator_write_mem,_emulator_set_breakpoint,_emulator_clear_breakpoints,_emulator_render_vram,_emulator_render_background,_emulator_get_banked_PC,_set_audio_channel_mute,_joypad_new,_joypad_delete,_rewind_append,_rewind_delete,_emulator_new_simple,_emulator_get_ticks_f64,_emulator_run_until_f64,_rewind_get_newest_ticks_f64,_rewind_get_oldest_ticks_f64,_emulator_set_default_joypad_callback,_emulator_set_bw_palette_simple,_rewind_new_simple,_rewind_begin,_emulator_set_rewind_joypad_callback,_rewind_to_ticks_wrapper,_rewind_end,_set_joyp_up,_set_joyp_down,_set_joyp_left,_set_joyp_right,_set_joyp_B,_set_joyp_A,_set_joyp_start,_set_joyp_select,_get_frame_buffer_ptr,_get_frame_buffer_size,_get_sgb_frame_buffer_ptr,_get_sgb_frame_buffer_size,_get_audio_buffer_ptr,_get_audio_buffer_capacity,_ext_ram_file_data_new,_state_file_data_new,_get_file_data_ptr,_get_file_data_size,_set_log_apu_writes,_get_apu_log_data_size,_get_apu_log_data_ptr,_reset_apu_log,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_malloc=Module["_malloc"]=wasmExports["f"];_file_data_delete=Module["_file_data_delete"]=wasmExports["g"];_free=Module["_free"]=wasmExports["h"];_emulator_set_builtin_palette=Module["_emulator_set_builtin_palette"]=wasmExports["i"];_emulator_was_ext_ram_updated=Module["_emulator_was_ext_ram_updated"]=wasmExports["j"];_emulator_read_state=Module["_emulator_read_state"]=wasmExports["k"];_emulator_write_state=Module["_emulator_write_state"]=wasmExports["l"];_emulator_read_ext_ram=Module["_emulator_read_ext_ram"]=wasmExports["m"];_emulator_write_ext_ram=Module["_emulator_write_ext_ram"]=wasmExports["n"];_emulator_delete=Module["_emulator_delete"]=wasmExports["o"];_emulator_get_PC=Module["_emulator_get_PC"]=wasmExports["p"];_emulator_get_A=Module["_emulator_get_A"]=wasmExports["q"];_emulator_get_BC=Module["_emulator_get_BC"]=wasmExports["r"];_emulator_get_DE=Module["_emulator_get_DE"]=wasmExports["s"];_emulator_get_HL=Module["_emulator_get_HL"]=wasmExports["t"];_emulator_get_F=Module["_emulator_get_F"]=wasmExports["u"];_emulator_get_SP=Module["_emulator_get_SP"]=wasmExports["v"];_emulator_set_PC=Module["_emulator_set_PC"]=wasmExports["w"];_emulator_get_wram_ptr=Module["_emulator_get_wram_ptr"]=wasmExports["x"];_emulator_get_hram_ptr=Module["_emulator_get_hram_ptr"]=wasmExports["y"];_emulator_read_mem=Module["_emulator_read_mem"]=wasmExports["z"];_emulator_write_mem=Module["_emulator_write_mem"]=wasmExports["A"];_emulator_set_breakpoint=Module["_emulator_set_breakpoint"]=wasmExports["B"];_emulator_clear_breakpoints=Module["_emulator_clear_breakpoints"]=wasmExports["C"];_emulator_render_vram=Module["_emulator_render_vram"]=wasmExports["D"];_emulator_render_background=Module["_emulator_render_background"]=wasmExports["E"];_emulator_get_banked_PC=Module["_emulator_get_banked_PC"]=wasmExports["F"];_set_audio_channel_mute=Module["_set_audio_channel_mute"]=wasmExports["G"];_joypad_new=Module["_joypad_new"]=wasmExports["H"];_joypad_delete=Module["_joypad_delete"]=wasmExports["I"];_rewind_append=Module["_rewind_append"]=wasmExports["J"];_rewind_delete=Module["_rewind_delete"]=wasmExports["K"];_emulator_new_simple=Module["_emulator_new_simple"]=wasmExports["L"];_emulator_get_ticks_f64=Module["_emulator_get_ticks_f64"]=wasmExports["M"];_emulator_run_until_f64=Module["_emulator_run_until_f64"]=wasmExports["N"];_rewind_get_newest_ticks_f64=Module["_rewind_get_newest_ticks_f64"]=wasmExports["O"];_rewind_get_oldest_ticks_f64=Module["_rewind_get_oldest_ticks_f64"]=wasmExports["P"];_emulator_set_default_joypad_callback=Module["_emulator_set_default_joypad_callback"]=wasmExports["Q"];_emulator_set_bw_palette_simple=Module["_emulator_set_bw_palette_simple"]=wasmExports["R"];_rewind_new_simple=Module["_rewind_new_simple"]=wasmExports["S"];_rewind_begin=Module["_rewind_begin"]=wasmExports["T"];_emulator_set_rewind_joypad_callback=Module["_emulator_set_rewind_joypad_callback"]=wasmExports["U"];_rewind_to_ticks_wrapper=Module["_rewind_to_ticks_wrapper"]=wasmExports["V"];_rewind_end=Module["_rewind_end"]=wasmExports["W"];_set_joyp_up=Module["_set_joyp_up"]=wasmExports["X"];_set_joyp_down=Module["_set_joyp_down"]=wasmExports["Y"];_set_joyp_left=Module["_set_joyp_left"]=wasmExports["Z"];_set_joyp_right=Module["_set_joyp_right"]=wasmExports["_"];_set_joyp_B=Module["_set_joyp_B"]=wasmExports["$"];_set_joyp_A=Module["_set_joyp_A"]=wasmExports["aa"];_set_joyp_start=Module["_set_joyp_start"]=wasmExports["ba"];_set_joyp_select=Module["_set_joyp_select"]=wasmExports["ca"];_get_frame_buffer_ptr=Module["_get_frame_buffer_ptr"]=wasmExports["da"];_get_frame_buffer_size=Module["_get_frame_buffer_size"]=wasmExports["ea"];_get_sgb_frame_buffer_ptr=Module["_get_sgb_frame_buffer_ptr"]=wasmExports["fa"];_get_sgb_frame_buffer_size=Module["_get_sgb_frame_buffer_size"]=wasmExports["ga"];_get_audio_buffer_ptr=Module["_get_audio_buffer_ptr"]=wasmExports["ha"];_get_audio_buffer_capacity=Module["_get_audio_buffer_capacity"]=wasmExports["ia"];_ext_ram_file_data_new=Module["_ext_ram_file_data_new"]=wasmExports["ja"];_state_file_data_new=Module["_state_file_data_new"]=wasmExports["ka"];_get_file_data_ptr=Module["_get_file_data_ptr"]=wasmExports["la"];_get_file_data_size=Module["_get_file_data_size"]=wasmExports["ma"];_set_log_apu_writes=Module["_set_log_apu_writes"]=wasmExports["na"];_get_apu_log_data_size=Module["_get_apu_log_data_size"]=wasmExports["oa"];_get_apu_log_data_ptr=Module["_get_apu_log_data_ptr"]=wasmExports["pa"];_reset_apu_log=Module["_reset_apu_log"]=wasmExports["qa"];memory=wasmMemory=wasmExports["d"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={c:_emscripten_resize_heap,b:_exit,a:_fd_write};function run(){preRun();function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;wasmExports=await (createWasm());run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})} +;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); diff --git a/reference/public/vendor/binjgb/binjgb.wasm b/reference/public/vendor/binjgb/binjgb.wasm new file mode 100644 index 0000000..03f903f Binary files /dev/null and b/reference/public/vendor/binjgb/binjgb.wasm differ diff --git a/scripts/check-reference.mjs b/scripts/check-reference.mjs new file mode 100644 index 0000000..0e046ea --- /dev/null +++ b/scripts/check-reference.mjs @@ -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.", +); diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs new file mode 100644 index 0000000..8d50a5d --- /dev/null +++ b/scripts/check-release.mjs @@ -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.`, +); diff --git a/scripts/check-vendor.mjs b/scripts/check-vendor.mjs new file mode 100644 index 0000000..546689f --- /dev/null +++ b/scripts/check-vendor.mjs @@ -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."); diff --git a/src/app/runtime.ts b/src/app/runtime.ts new file mode 100644 index 0000000..9d3eb2c --- /dev/null +++ b/src/app/runtime.ts @@ -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; + play(): void; + pause(): void; + setButton(button: Button, pressed: boolean): void; + captureState(): Uint8Array; + restoreState(state: Uint8Array): void; + dispose(): void; +} diff --git a/src/app/session.ts b/src/app/session.ts new file mode 100644 index 0000000..c8f241e --- /dev/null +++ b/src/app/session.ts @@ -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 = 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; + } + } +} diff --git a/src/emulator/binjgb.ts b/src/emulator/binjgb.ts new file mode 100644 index 0000000..aac9820 --- /dev/null +++ b/src/emulator/binjgb.ts @@ -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; + } +} +export const CORE_ID = "binjgb-c60e138da5a795ebb55e56b11b7e90024e41112c"; +let scriptReady: Promise | 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((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(); + 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; + } +} diff --git a/src/emulator/types.ts b/src/emulator/types.ts new file mode 100644 index 0000000..e29e2d4 --- /dev/null +++ b/src/emulator/types.ts @@ -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; + 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; +} diff --git a/src/game/rendering/pixels.ts b/src/game/rendering/pixels.ts new file mode 100644 index 0000000..78bd300 --- /dev/null +++ b/src/game/rendering/pixels.ts @@ -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; + } + } +} diff --git a/src/game/rendering/renderer.ts b/src/game/rendering/renderer.ts new file mode 100644 index 0000000..6ef787d --- /dev/null +++ b/src/game/rendering/renderer.ts @@ -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), + }, + ); + } + } +} diff --git a/src/game/rom/assets.ts b/src/game/rom/assets.ts new file mode 100644 index 0000000..bb3ea58 --- /dev/null +++ b/src/game/rom/assets.ts @@ -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(); + private tilesets = new Map(); + private sprites = new Map(); + 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), + }; + }); + } +} diff --git a/src/game/rom/lz.ts b/src/game/rom/lz.ts new file mode 100644 index 0000000..4f47bef --- /dev/null +++ b/src/game/rom/lz.ts @@ -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."); +} diff --git a/src/game/rom/profile.ts b/src/game/rom/profile.ts new file mode 100644 index 0000000..97f4119 --- /dev/null +++ b/src/game/rom/profile.ts @@ -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 = { + "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; diff --git a/src/game/rom/reader.ts b/src/game/rom/reader.ts new file mode 100644 index 0000000..0e66fbd --- /dev/null +++ b/src/game/rom/reader.ts @@ -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.", + ); + } + } +} diff --git a/src/game/runtime.ts b/src/game/runtime.ts new file mode 100644 index 0000000..72aa08b --- /dev/null +++ b/src/game/runtime.ts @@ -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`; + +export function renderShell(root: HTMLElement) { + root.innerHTML = ` + +
+
+

✧ YOUR CARTRIDGE. A NEW HOME.

+

Original game.
Reference only.

+

Development reference for the native port.
Bring your own ROM. Pick up where you left off.

+
+
01

Bring your cartridge

+

Pokémon Crystal
English US/Europe · Revision 1.1

+ + +

Opened locally. Never uploaded.

+
+

A small beginning, built for more.
Touch menus, online linking, and walking features are planned.

+
+
+
+
CRYSTAL / 01STANDBY
+
+
+ +

Your next chapter
starts here.

Load your Crystal ROM to begin.

+ +
+
GAME BOY COLOR160 × 144
+
+
+
${key("up", "▲")}${key("left", "◀")}${key("right", "▶")}${key("down", "▼")}
+
${key("b", "B")}${key("a", "A")}
+
${key("select", "SELECT")}${key("start", "START")}
+ +
+
MADE FOR THE WAY YOU PLAY✧
+
+
+

Ready when you are. Choose your ROM to get started.

+

Session backups stay in this browser.

+
Save files & compatibility

Use the game’s SAVE menu before exporting a .sav. Session backups also preserve your current screen and clock state.

The game clock currently advances only while playing. Time while paused or closed is not added. Export a backup before clearing browser data.

+
+
+
A personal cartridge, a pocket-sized world.Independent project · No game assets included
+

GETTING STARTED

Make yourself at home.

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.

Use the onscreen controls, or focus the game screen and use:

Move
Arrow keys
A / B
X / Z
Start / Select
Enter / Shift

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.

The original game menus remain intact for this first version. Internet Link and step counting are not available yet.

`; +} diff --git a/src/rom/validate.ts b/src/rom/validate.ts new file mode 100644 index 0000000..37eff02 --- /dev/null +++ b/src/rom/validate.ts @@ -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 { + 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 }; +} diff --git a/src/storage/saves.ts b/src/storage/saves.ts new file mode 100644 index 0000000..bb51727 --- /dev/null +++ b/src/storage/saves.ts @@ -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; + write(record: SaveRecord): Promise; +} + +export class BrowserSaveRepository implements SaveRepository { + private database?: Promise; + constructor(private databaseName = "crystal-player") {} + private open() { + return (this.database ??= new Promise((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((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((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 }; +} diff --git a/src/ui/shell.ts b/src/ui/shell.ts new file mode 100644 index 0000000..b5178ab --- /dev/null +++ b/src/ui/shell.ts @@ -0,0 +1,55 @@ +const key = (name: string, label: string, className = "") => + ``; + +export function renderShell(root: HTMLElement) { + root.innerHTML = ` + +
+
+

✧ YOUR CARTRIDGE. A NEW HOME.

+

A little adventure.
Anywhere.

+

Crystal, rebuilt for the web.
Start exploring New Bark Town in the first native milestone.

+
+
01

Bring your cartridge

+

Pokémon Crystal
English US/Europe · Revision 1.1

+
Used for your first session. Saved sessions keep their character.
+ + +

Opened locally. Never uploaded.

+
+

An exploration build, not the full game yet.
Move, enter buildings, read signs and try interactions. Story progression, battles and audio are next.

+
+
+
+
NATIVE / EXPLORATIONSTANDBY
+
+
+ + +

Your next chapter
starts here.

Load your Crystal ROM to begin.

+ +
+
YOUR WORLD, REBUILT160 × 144
+
+
+
${key("up", "▲")}${key("left", "◀")}${key("right", "▶")}${key("down", "▼")}
+
${key("b", "B")}${key("a", "A")}
+
${key("select", "SELECT")}${key("start", "START")}
+ +
+
MADE FOR THE WAY YOU PLAY✧
+
+
+

0 tiles walkedExploration build · Audio pending

+

+

Ready when you are. Choose your ROM to get started.

+

Session backups stay in this browser.

+
Save files & compatibility

Native saves keep your character, position and completed interactions. They are separate from emulator snapshots and cartridge .sav files, which cannot be imported yet.

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.

+
+
+
A personal cartridge, a pocket-sized world.Independent project · No game assets included
+

NATIVE PORT · FIRST MILESTONE

Make yourself at home.

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.

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.

Move
Arrow keys
Interact / Continue
X / Z or A / B

Sessions save every 10 seconds and when paused. Download a backup before clearing browser data. Switching apps pauses play.

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.

`; +} diff --git a/src/ui/styles.css b/src/ui/styles.css new file mode 100644 index 0000000..ec385dd --- /dev/null +++ b/src/ui/styles.css @@ -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; + } +} diff --git a/tests/browser.spec.ts b/tests/browser.spec.ts new file mode 100644 index 0000000..3522fe2 --- /dev/null +++ b/tests/browser.spec.ts @@ -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(); + 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([]); +}); diff --git a/tests/core.test.ts b/tests/core.test.ts new file mode 100644 index 0000000..3928aa2 --- /dev/null +++ b/tests/core.test.ts @@ -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)); + }); +}); diff --git a/tests/native-browser.spec.ts b/tests/native-browser.spec.ts new file mode 100644 index 0000000..03ed4bb --- /dev/null +++ b/tests/native-browser.spec.ts @@ -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([]); +}); diff --git a/tests/native.test.ts b/tests/native.test.ts new file mode 100644 index 0000000..dbbaa3e --- /dev/null +++ b/tests/native.test.ts @@ -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(), + flags: new Set(), + 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(); + 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); + }); + }, +); diff --git a/tests/reference-parity.spec.ts b/tests/reference-parity.spec.ts new file mode 100644 index 0000000..647157d --- /dev/null +++ b/tests/reference-parity.spec.ts @@ -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); + } +}); diff --git a/tests/synthetic-rom.ts b/tests/synthetic-rom.ts new file mode 100644 index 0000000..1c07407 --- /dev/null +++ b/tests/synthetic-rom.ts @@ -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; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fc80fe7 --- /dev/null +++ b/tsconfig.json @@ -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"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..c6fc3aa --- /dev/null +++ b/vite.config.ts @@ -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" }, +})); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..60b59f0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ test: { include: ["tests/**/*.test.ts"] } });