diff --git a/CLAUDE.md b/CLAUDE.md index 918c0eb0..7ddbc679 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,32 +11,50 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `npm run build` - Build distribution files (`dist/jsnes.js` and `dist/jsnes.min.js`) - `npm run format` - Auto-format all code (core and web) with Prettier - `npm run test:watch` - Run tests in watch mode for development -- `npm run typecheck` - TypeScript type checking (verifies `.d.ts` files) -- `node bench.js` - Run performance benchmark (~1800 fps baseline). Run this on major changes or when you suspect a performance regression/improvement. +- `npm run typecheck` - TypeScript type checking +- `node --experimental-strip-types bench.js` - Run performance benchmark (~1800 fps baseline). Run this on major changes or when you suspect a performance regression/improvement. + +## TypeScript + +The source code is written in TypeScript (`.ts` files in `src/`). The conversion +was done in a "loose" style — most files currently have `// @ts-nocheck` at the +top to allow the existing JavaScript patterns (dynamic property assignment, +implicit `any`, etc.) to continue working unchanged. Types are intended to be +added incrementally. + +- Source files: `src/**/*.ts` +- Imports use `.ts` extensions (e.g. `import CPU from "./cpu.ts"`) so Node.js + can run them directly with `--experimental-strip-types`. +- Test files remain as `.js` but import `.ts` source files directly. +- Build: webpack + `ts-loader` (with `transpileOnly: true`) bundles the TS + source into `dist/jsnes.js` and `dist/jsnes.min.js`. +- Tests: run with `node --experimental-strip-types --test test/*.spec.js`. +- `tsc --noEmit` is used for type checking only. ## Code Architecture -JSNES is a JavaScript NES emulator with component-based architecture mirroring actual NES hardware: +JSNES is a NES emulator written in TypeScript, with a component-based +architecture mirroring actual NES hardware: ### Core Components (all in `src/`) -**Main Orchestrator**: `nes.js` - Central class that coordinates all emulation components. Accepts callback functions for frame rendering, audio output, and status updates. +**Main Orchestrator**: `nes.ts` - Central class that coordinates all emulation components. Accepts callback functions for frame rendering, audio output, and status updates. -**CPU**: `cpu.js` - Implements 6502 processor with 64KB address space, instruction execution, and interrupt handling (NMI, IRQ, reset). +**CPU**: `cpu.ts` - Implements 6502 processor with 64KB address space, instruction execution, and interrupt handling (NMI, IRQ, reset). -**PPU**: `ppu.js` - Picture Processing Unit handles 256x240 graphics rendering, VRAM management, background/sprite rendering, and scrolling. +**PPU**: `ppu/index.ts` - Picture Processing Unit handles 256x240 graphics rendering, VRAM management, background/sprite rendering, and scrolling. -**PAPU**: `papu.js` - Audio Processing Unit implements NES's 5 audio channels (2 square waves, triangle, noise, DMC) with 44.1kHz/48kHz sample generation. +**PAPU**: `papu/index.ts` - Audio Processing Unit implements NES's 5 audio channels (2 square waves, triangle, noise, DMC) with 44.1kHz/48kHz sample generation. -**Memory Mappers**: `mappers.js` - Implements cartridge memory mappers (0-180) using inheritance hierarchy. All mappers inherit from Mapper 0 and override specific banking/memory mapping behavior. +**Memory Mappers**: `mappers/` - Implements cartridge memory mappers (0-180) using inheritance hierarchy. All mappers inherit from Mapper 0 and override specific banking/memory mapping behavior. -**ROM Loader**: `rom.js` - Parses iNES format ROM files, extracts PRG-ROM/CHR-ROM, and determines appropriate mapper. +**ROM Loader**: `rom.ts` - Parses iNES format ROM files, extracts PRG-ROM/CHR-ROM, and determines appropriate mapper. -**Tile Renderer**: `tile.js` - Handles 8x8 pixel tile rendering with sprite flipping (horizontal/vertical), alpha-blending priority, and scanline-based rendering. +**Tile Renderer**: `tile.ts` - Handles 8x8 pixel tile rendering with sprite flipping (horizontal/vertical), alpha-blending priority, and scanline-based rendering. -**Controller**: `controller.js` - Button state management for 2 controllers with 8 buttons each (A, B, SELECT, START, UP, DOWN, LEFT, RIGHT) and serial strobe protocol. +**Controller**: `controller.ts` - Button state management for 2 controllers with 8 buttons each (A, B, SELECT, START, UP, DOWN, LEFT, RIGHT) and serial strobe protocol. -**Utilities**: `utils.js` - Helper functions for state serialization (`toJSON()`/`fromJSON()`) used across CPU, PPU, PAPU, and mappers for save state support. +**Utilities**: `utils.ts` - Helper functions for state serialization (`toJSON()`/`fromJSON()`) used across CPU, PPU, PAPU, and mappers for save state support. ### Key Architectural Patterns @@ -78,11 +96,10 @@ Remember that AccuracyCoin and nestest are DEFINITELY correct. They pass on a re ## Build Process Webpack configuration creates UMD modules compatible with browsers and Node.js: -- Entry point: `src/index.js` (exports NES and Controller classes) +- Entry point: `src/index.ts` (exports NES and Controller classes) - Output: `dist/jsnes.js` (regular) and `dist/jsnes.min.js` (minified) -- Includes ESLint checking and source map generation +- Uses `ts-loader` (in `transpileOnly` mode) to transpile TypeScript - Library name: `jsnes` (global variable in browsers) -- TypeScript type definitions: `src/nes.d.ts` and `src/controller.d.ts` provide public API types - CI: GitHub Actions (`.github/workflows/ci.yaml`) runs build and tests on push/PR with Node.js 22.x ## Code Quality Requirements @@ -122,13 +139,13 @@ Dummy reads occur in: - **Post-indexed indirect** (case 11): Same page-crossing behavior as absolute indexed - **Stores** (STA/STX/STY) and **RMW instructions**: Always perform the indexed dummy read, even without page crossing -RMW instructions (ASL, LSR, ROL, ROR, INC, DEC, and unofficial SLO, SRE, RLA, RRA, DCP, ISC) also perform a "dummy write" — they write the original value back to the address before writing the modified value. This is documented in the ASL implementation (case 2) in `cpu.js`. +RMW instructions (ASL, LSR, ROL, ROR, INC, DEC, and unofficial SLO, SRE, RLA, RRA, DCP, ISC) also perform a "dummy write" — they write the original value back to the address before writing the modified value. This is documented in the ASL implementation (case 2) in `cpu.ts`. ### PPU Catch-up On real hardware, the CPU and PPU advance in lockstep (3 PPU dots per CPU cycle). The emulator runs CPU instructions atomically for performance, then advances the PPU afterward. This means PPU register reads mid-instruction (e.g., reading VBlank status from `$2002`) would see stale PPU state. -To fix this, `cpu.load()` and `cpu.write()` call `_ppuCatchUp()` before any PPU register access ($2000-$3FFF). This method advances the PPU by `instrBusCycles * 3` dots — the number of PPU dots that should have elapsed based on how many bus operations the instruction has completed so far. The frame loop in `nes.js` then subtracts the already-advanced dots from the total. +To fix this, `cpu.load()` and `cpu.write()` call `_ppuCatchUp()` before any PPU register access ($2000-$3FFF). This method advances the PPU by `instrBusCycles * 3` dots — the number of PPU dots that should have elapsed based on how many bus operations the instruction has completed so far. The frame loop in `nes.ts` then subtracts the already-advanced dots from the total. See https://www.nesdev.org/wiki/Catch-up diff --git a/README.md b/README.md index bb11f814..183e532a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # JSNES -A JavaScript NES emulator. +A NES emulator written in TypeScript. It's a library that works in both the browser and Node.js. @@ -225,7 +225,8 @@ To build a distribution: $ npm run build -This will create `dist/jsnes.min.js`. +This will create `dist/jsnes.js` and `dist/jsnes.min.js` from the TypeScript +sources under `src/`. ## Running tests diff --git a/bench.js b/bench.js index 8addd8d0..0516b0d9 100644 --- a/bench.js +++ b/bench.js @@ -10,7 +10,7 @@ import fs from "fs"; import path from "path"; -import NES from "./src/nes.js"; +import NES from "./src/nes.ts"; const ROMS = { croom: "roms/croom/croom.nes", diff --git a/index.d.ts b/index.d.ts deleted file mode 100644 index 2c908fff..00000000 --- a/index.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./src/nes"; -export * from "./src/controller"; -export * from "./src/browser"; -export * from "./src/gamegenie"; diff --git a/package-lock.json b/package-lock.json index 77015366..8c282a52 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,9 @@ "@types/node": "^25.0.2", "eslint": "^10.0.2", "eslint-config-prettier": "^10.1.8", - "eslint-webpack-plugin": "^6.0.0", "prettier": "^3.6.2", "terser-webpack-plugin": "^5.3.10", + "ts-loader": "^9.5.7", "typescript": "^6.0.2", "webpack": "^5.100.2", "webpack-cli": "^7.0.2" @@ -598,6 +598,22 @@ "dev": true, "license": "MIT" }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/balanced-match": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", @@ -706,6 +722,23 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/chrome-trace-event": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", @@ -731,6 +764,26 @@ "node": ">=6" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/commander": { "version": "14.0.3", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", @@ -998,30 +1051,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint-webpack-plugin": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/eslint-webpack-plugin/-/eslint-webpack-plugin-6.0.0.tgz", - "integrity": "sha512-x9m9cH0Rw0RNJB/utP9AMGzmuXg/yLP/FCHSVSfsyjQUyetXN4g1BaIqxkj1i3mVX48aOys0bsVt63LLfh6oYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/eslint": "^9.6.1", - "micromatch": "^4.0.8", - "normalize-path": "^3.0.0", - "schema-utils": "^4.3.3" - }, - "engines": { - "node": ">= 20.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" - }, - "peerDependencies": { - "eslint": "^9.0.0 || ^10.0.0", - "webpack": "^5.0.0" - } - }, "node_modules/espree": { "version": "11.2.0", "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", @@ -1666,16 +1695,6 @@ "dev": true, "license": "MIT" }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -1923,6 +1942,19 @@ "dev": true, "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shallow-clone": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", @@ -1957,6 +1989,19 @@ "source-map": "^0.6.0" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -2088,6 +2133,37 @@ "node": ">=8.0" } }, + "node_modules/ts-loader": { + "version": "9.5.7", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.7.tgz", + "integrity": "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-loader/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 4e3e3dad..817486bb 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,15 @@ { "name": "jsnes", "version": "2.1.0", - "description": "A JavaScript NES emulator", + "description": "A NES emulator written in TypeScript", "homepage": "https://github.com/bfirsh/jsnes", "author": "Ben Firshman ", "main": "dist/jsnes.js", "exports": { ".": { - "types": "./index.d.ts", - "import": "./src/index.js", + "import": "./src/index.ts", "require": "./dist/jsnes.js", - "default": "./src/index.js" + "default": "./dist/jsnes.js" } }, "repository": { @@ -19,34 +18,29 @@ }, "files": [ "dist", - "src", - "index.d.ts" + "src" ], "license": "Apache-2.0", "type": "module", "scripts": { "build": "webpack", "typecheck": "tsc --noEmit", - "test": "npm run typecheck && prettier --check '**/*.{js,jsx,css}' && node --test ./test/*.spec.js", - "test:watch": "node --test --watch ./test/*.spec.js", + "test": "npm run typecheck && prettier --check '**/*.{ts,js,jsx,css}' && node --experimental-strip-types --disable-warning=ExperimentalWarning --test ./test/*.spec.js", + "test:watch": "node --experimental-strip-types --disable-warning=ExperimentalWarning --test --watch ./test/*.spec.js", "prepublishOnly": "npm run build", - "format": "prettier --write '**/*.{js,jsx,css}'" + "format": "prettier --write '**/*.{ts,js,jsx,css}'" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^25.0.2", "eslint": "^10.0.2", "eslint-config-prettier": "^10.1.8", - "eslint-webpack-plugin": "^6.0.0", "prettier": "^3.6.2", "terser-webpack-plugin": "^5.3.10", + "ts-loader": "^9.5.7", "typescript": "^6.0.2", "webpack": "^5.100.2", "webpack-cli": "^7.0.2" }, - "overrides": { - "eslint-webpack-plugin": { - "eslint": "$eslint" - } - } + "overrides": {} } diff --git a/src/browser.d.ts b/src/browser.d.ts deleted file mode 100644 index f8646209..00000000 --- a/src/browser.d.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { NES } from "./nes"; - -export interface BrowserOptions { - /** The container element to render into. */ - container: HTMLElement; - /** ROM data to load immediately. If omitted, call loadROM() then start(). */ - romData?: string | null; - /** Called when the emulator encounters an error during frame execution. */ - onError?: (error: Error) => void; - /** Called when battery-backed SRAM is written. */ - onBatteryRamWrite?: (address: number, value: number) => void; -} - -export class Browser { - /** The underlying NES instance. */ - readonly nes: NES; - /** The keyboard controller for configuring key mappings. */ - readonly keyboard: { - keys: Record; - loadKeys: () => void; - setKeys: (keys: Record) => void; - }; - /** The gamepad controller for configuring gamepad mappings. */ - readonly gamepad: { - gamepadConfig: unknown; - loadGamepadConfig: () => void; - setGamepadConfig: (config: unknown) => void; - promptButton: (callback: ((buttonInfo: unknown) => void) | null) => void; - }; - - constructor(options: BrowserOptions); - - /** Start emulation. Called automatically if romData is provided to constructor. */ - start(): void; - /** Pause emulation. */ - stop(): void; - /** Load a new ROM and start emulation. */ - loadROM(data: string): void; - /** Re-layout the canvas to fill its container. */ - fitInParent(): void; - /** Get a screenshot as an HTMLImageElement. */ - screenshot(): HTMLImageElement; - /** Clean up all resources: stop emulation, remove listeners, remove canvas. */ - destroy(): void; - - /** Load ROM data from a URL via XHR. */ - static loadROMFromURL( - url: string, - callback: (error: Error | null, data?: string) => void, - ): XMLHttpRequest; -} diff --git a/src/browser/frame-timer.js b/src/browser/frame-timer.ts similarity index 99% rename from src/browser/frame-timer.js rename to src/browser/frame-timer.ts index cfc5dd52..06ba976b 100644 --- a/src/browser/frame-timer.js +++ b/src/browser/frame-timer.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // Debug logging, enabled via localStorage.jsnes_debug = 1 let debugEnabled = false; try { diff --git a/src/browser/gamepad.js b/src/browser/gamepad.ts similarity index 99% rename from src/browser/gamepad.js rename to src/browser/gamepad.ts index 9981e7e1..57cace45 100644 --- a/src/browser/gamepad.js +++ b/src/browser/gamepad.ts @@ -1,3 +1,4 @@ +// @ts-nocheck export default class GamepadController { constructor(options) { this.onButtonDown = options.onButtonDown; diff --git a/src/browser/index.js b/src/browser/index.ts similarity index 69% rename from src/browser/index.js rename to src/browser/index.ts index c14858e1..b2ff9344 100644 --- a/src/browser/index.js +++ b/src/browser/index.ts @@ -1,9 +1,28 @@ -import NES from "../nes.js"; -import Screen from "./screen.js"; -import Speakers from "./speakers.js"; -import FrameTimer from "./frame-timer.js"; -import KeyboardController from "./keyboard.js"; -import GamepadController from "./gamepad.js"; +import NES from "../nes.ts"; +import type { RomData } from "../nes.ts"; +import Screen from "./screen.ts"; +import Speakers from "./speakers.ts"; +import FrameTimer from "./frame-timer.ts"; +import KeyboardController from "./keyboard.ts"; +import GamepadController from "./gamepad.ts"; + +export interface BrowserOptions { + /** The container element to render into. */ + container: HTMLElement; + /** ROM data to load immediately. If omitted, call loadROM() then start(). */ + romData?: RomData | null; + /** Called when the emulator encounters an error during frame execution. */ + onError?: (error: unknown) => void; + /** Called when battery-backed SRAM is written. */ + onBatteryRamWrite?: (address: number, value: number) => void; +} + +// The browser helper classes (Screen, Speakers, FrameTimer, etc.) still +// have @ts-nocheck for a loose conversion, so their public shapes are not +// accurately typed. We use `any` for their field types here so consumers +// of the Browser class still get precise types on the public API surface. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Internal = any; // Debug logging, enabled via localStorage.jsnes_debug = 1 let debugEnabled = false; @@ -12,7 +31,7 @@ try { } catch { // localStorage not available } -function debug(...args) { +function debug(...args: unknown[]): void { if (debugEnabled) console.log(...args); } @@ -30,12 +49,23 @@ function debug(...args) { * If romData is omitted, call browser.loadROM(data) then browser.start(). */ export default class Browser { - constructor(options = {}) { + readonly nes: NES; + readonly keyboard: Internal; + readonly gamepad: Internal; + + private _options: BrowserOptions; + private _screen: Internal; + private _speakers: Internal; + private _frameTimer: Internal; + private _gamepadPolling: Internal; + private _fpsInterval: ReturnType | undefined; + + constructor(options: BrowserOptions) { this._options = options; // Create screen (creates inside container) this._screen = new Screen(options.container, { - onMouseDown: (x, y) => { + onMouseDown: (x: number, y: number) => { this.nes.zapperMove(x, y); this.nes.zapperFireDown(); }, @@ -116,7 +146,8 @@ export default class Browser { } } - start() { + /** Start emulation. Called automatically if romData is provided to constructor. */ + start(): void { this._frameTimer.start(); this._speakers.start(); this._fpsInterval = setInterval(() => { @@ -124,13 +155,15 @@ export default class Browser { }, 1000); } - stop() { + /** Pause emulation. */ + stop(): void { this._frameTimer.stop(); this._speakers.stop(); clearInterval(this._fpsInterval); } - loadROM(data) { + /** Load a new ROM and start emulation. */ + loadROM(data: RomData): void { this.stop(); this.nes.loadROM(data); this.start(); @@ -139,18 +172,19 @@ export default class Browser { /** * Fill parent element with screen. Call if parent element changes size. */ - fitInParent() { + fitInParent(): void { this._screen.fitInParent(); } - screenshot() { + /** Get a screenshot as an HTMLImageElement. */ + screenshot(): HTMLImageElement { return this._screen.screenshot(); } /** * Clean up all resources: stop emulation, remove event listeners, remove canvas. */ - destroy() { + destroy(): void { this.stop(); document.removeEventListener("keydown", this.keyboard.handleKeyDown); document.removeEventListener("keyup", this.keyboard.handleKeyUp); @@ -162,7 +196,10 @@ export default class Browser { /** * Load ROM data from a URL via XHR. */ - static loadROMFromURL(url, callback) { + static loadROMFromURL( + url: string, + callback: (error: Error | null, data?: string) => void, + ): XMLHttpRequest { var req = new XMLHttpRequest(); req.open("GET", url); req.overrideMimeType("text/plain; charset=x-user-defined"); @@ -174,7 +211,7 @@ export default class Browser { } else if (this.status === 0) { // Aborted, ignore } else { - req.onerror(); + req.onerror!(new ProgressEvent("error")); } }; req.send(); diff --git a/src/browser/keyboard.js b/src/browser/keyboard.ts similarity index 97% rename from src/browser/keyboard.js rename to src/browser/keyboard.ts index 20e6bb4c..da4d473e 100644 --- a/src/browser/keyboard.js +++ b/src/browser/keyboard.ts @@ -1,4 +1,5 @@ -import Controller from "../controller.js"; +// @ts-nocheck +import Controller from "../controller.ts"; // Mapping keyboard code to [controller, button] const KEYS = { diff --git a/src/browser/screen.js b/src/browser/screen.ts similarity index 99% rename from src/browser/screen.js rename to src/browser/screen.ts index d8617623..01fb021c 100644 --- a/src/browser/screen.js +++ b/src/browser/screen.ts @@ -1,3 +1,4 @@ +// @ts-nocheck const SCREEN_WIDTH = 256; const SCREEN_HEIGHT = 240; diff --git a/src/browser/speakers.js b/src/browser/speakers.ts similarity index 99% rename from src/browser/speakers.js rename to src/browser/speakers.ts index d1f353a5..fe04db8e 100644 --- a/src/browser/speakers.js +++ b/src/browser/speakers.ts @@ -1,3 +1,4 @@ +// @ts-nocheck // AudioWorklet processor code, inlined as a string so it can be loaded via // Blob URL without bundler-specific imports (e.g. ?raw). This avoids // requiring webpack/Vite to import the module source. diff --git a/src/controller.d.ts b/src/controller.d.ts deleted file mode 100644 index 6b7690e3..00000000 --- a/src/controller.d.ts +++ /dev/null @@ -1,49 +0,0 @@ -export type ButtonKey = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; - -export class Controller { - state: number[]; - baseA: number; - baseB: number; - turboA: boolean; - turboB: boolean; - turboToggle: boolean; - - buttonDown: (key: ButtonKey) => void; - buttonUp: (key: ButtonKey) => void; - clock: () => void; - toJSON(): { - state: number[]; - baseA: number; - baseB: number; - turboA: boolean; - turboB: boolean; - turboToggle: boolean; - }; - fromJSON(state: { - state: number[]; - baseA: number; - baseB: number; - turboA: boolean; - turboB: boolean; - turboToggle: boolean; - }): void; - - static readonly BUTTON_A = 0; - static readonly BUTTON_B = 1; - static readonly BUTTON_SELECT = 2; - static readonly BUTTON_START = 3; - static readonly BUTTON_UP = 4; - static readonly BUTTON_DOWN = 5; - static readonly BUTTON_LEFT = 6; - static readonly BUTTON_RIGHT = 7; - static readonly BUTTON_TURBO_A = 8; - static readonly BUTTON_TURBO_B = 9; - static readonly JSON_PROPERTIES: readonly [ - "state", - "baseA", - "baseB", - "turboA", - "turboB", - "turboToggle", - ]; -} diff --git a/src/controller.js b/src/controller.ts similarity index 66% rename from src/controller.js rename to src/controller.ts index 89de86cb..09d751b1 100644 --- a/src/controller.js +++ b/src/controller.ts @@ -1,27 +1,45 @@ -import { toJSON, fromJSON } from "./utils.js"; +import { toJSON, fromJSON } from "./utils.ts"; + +export type ButtonKey = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + +export interface ControllerState { + state: number[]; + baseA: number; + baseB: number; + turboA: boolean; + turboB: boolean; + turboToggle: boolean; +} class Controller { - static BUTTON_A = 0; - static BUTTON_B = 1; - static BUTTON_SELECT = 2; - static BUTTON_START = 3; - static BUTTON_UP = 4; - static BUTTON_DOWN = 5; - static BUTTON_LEFT = 6; - static BUTTON_RIGHT = 7; + static readonly BUTTON_A = 0; + static readonly BUTTON_B = 1; + static readonly BUTTON_SELECT = 2; + static readonly BUTTON_START = 3; + static readonly BUTTON_UP = 4; + static readonly BUTTON_DOWN = 5; + static readonly BUTTON_LEFT = 6; + static readonly BUTTON_RIGHT = 7; // Turbo buttons rapidly toggle A/B each frame while held, simulating the // extra buttons on the NES Advantage and dogbone controllers. - static BUTTON_TURBO_A = 8; - static BUTTON_TURBO_B = 9; + static readonly BUTTON_TURBO_A = 8; + static readonly BUTTON_TURBO_B = 9; - static JSON_PROPERTIES = [ + static readonly JSON_PROPERTIES = [ "state", "baseA", "baseB", "turboA", "turboB", "turboToggle", - ]; + ] as const; + + state: number[]; + baseA: number; + baseB: number; + turboA: boolean; + turboB: boolean; + turboToggle: boolean; constructor() { this.state = new Array(8); @@ -37,7 +55,7 @@ class Controller { this.turboToggle = false; } - buttonDown(key) { + buttonDown(key: ButtonKey): void { if (key === Controller.BUTTON_TURBO_A) { this.turboA = true; } else if (key === Controller.BUTTON_TURBO_B) { @@ -49,7 +67,7 @@ class Controller { } } - buttonUp(key) { + buttonUp(key: ButtonKey): void { if (key === Controller.BUTTON_TURBO_A) { this.turboA = false; this.state[Controller.BUTTON_A] = this.baseA; @@ -66,7 +84,7 @@ class Controller { // Called once per frame to toggle turbo button states. Produces a ~30 Hz // press rate at 60 FPS, matching the fast end of the NES Advantage's // adjustable turbo range. - clock() { + clock(): void { if (!this.turboA && !this.turboB) return; this.turboToggle = !this.turboToggle; if (this.turboA) { @@ -77,11 +95,11 @@ class Controller { } } - toJSON() { - return toJSON(this); + toJSON(): ControllerState { + return toJSON(this) as ControllerState; } - fromJSON(s) { + fromJSON(s: ControllerState): void { fromJSON(this, s); } } diff --git a/src/cpu.js b/src/cpu.ts similarity index 99% rename from src/cpu.js rename to src/cpu.ts index 4c19ff50..9b04917e 100644 --- a/src/cpu.js +++ b/src/cpu.ts @@ -1,4 +1,5 @@ -import { fromJSON, toJSON } from "./utils.js"; +// @ts-nocheck +import { fromJSON, toJSON } from "./utils.ts"; // ============================================================================ // 6502 opcode table diff --git a/src/gamegenie.d.ts b/src/gamegenie.d.ts deleted file mode 100644 index 4fc0ed29..00000000 --- a/src/gamegenie.d.ts +++ /dev/null @@ -1,27 +0,0 @@ -export interface GameGeniePatch { - addr: number; - value: number; - wantskey: boolean; - key?: number; -} - -export class GameGenie { - patches: GameGeniePatch[]; - enabled: boolean; - onChange: (() => void) | null; - - setEnabled: (enabled: boolean) => void; - addCode: (code: string) => void; - addPatch: (addr: number, value: number, key?: number) => void; - removeAllCodes: () => void; - applyCodes: (addr: number, value: number) => number; - decode: (code: string) => GameGeniePatch | null; - encodeHex: ( - addr: number, - value: number, - key?: number, - wantskey?: boolean, - ) => string; - decodeHex: (s: string) => GameGeniePatch | null; - encode: (addr: number, value: number, key?: number, wantskey?: boolean) => string; -} diff --git a/src/gamegenie.js b/src/gamegenie.ts similarity index 75% rename from src/gamegenie.js rename to src/gamegenie.ts index 5ed0ef79..f0bc85d1 100644 --- a/src/gamegenie.js +++ b/src/gamegenie.ts @@ -1,34 +1,45 @@ const LETTER_VALUES = "APZLGITYEOXUKSVN"; -function toDigit(letter) { +function toDigit(letter: string): number { return LETTER_VALUES.indexOf(letter); } -function toLetter(digit) { +function toLetter(digit: number): string { return LETTER_VALUES[digit]; } -function toHex(n, width) { +function toHex(n: number, width: number): string { const s = n.toString(16); return "0000".substring(0, width - s.length) + s; } +export interface GameGeniePatch { + addr: number; + value: number; + wantskey?: boolean; + key?: number; +} + class GameGenie { + patches: GameGeniePatch[]; + enabled: boolean; + // Callback invoked when patches or enabled state change, so the CPU + // can swap its loadFromCartridge function pointer. Set by NES after + // construction. + onChange: (() => void) | null; + constructor() { this.patches = []; this.enabled = true; - // Callback invoked when patches or enabled state change, so the CPU - // can swap its loadFromCartridge function pointer. Set by NES after - // construction. this.onChange = null; } - setEnabled(enabled) { + setEnabled(enabled: boolean): void { this.enabled = enabled; if (this.onChange) this.onChange(); } - addCode(code) { + addCode(code: string): void { const patch = this.decode(code); if (!patch) { throw new Error(`Invalid Game Genie code: ${code}`); @@ -37,12 +48,12 @@ class GameGenie { if (this.onChange) this.onChange(); } - addPatch(addr, value, key) { + addPatch(addr: number, value: number, key?: number): void { this.patches.push({ addr, value, key }); if (this.onChange) this.onChange(); } - removeAllCodes() { + removeAllCodes(): void { this.patches = []; if (this.onChange) this.onChange(); } @@ -51,7 +62,7 @@ class GameGenie { // Game Genie works by intercepting ROM reads and substituting values. // The address is masked to 15 bits because Game Genie ignores the // highest bit (ROM is mirrored in $8000-$FFFF). - applyCodes(addr, value) { + applyCodes(addr: number, value: number): number { if (!this.enabled) return value; for (let i = 0; i < this.patches.length; ++i) { @@ -67,7 +78,7 @@ class GameGenie { return value; } - decode(code) { + decode(code: string): GameGeniePatch | null { if (code.includes(":")) return this.decodeHex(code); const digits = code.toUpperCase().split("").map(toDigit); @@ -82,7 +93,7 @@ class GameGenie { ((digits[2] & 7) << 4) + (digits[3] & 8) + (digits[4] & 7); - let key; + let key: number | undefined; if (digits.length === 8) { value += digits[7] & 8; @@ -100,7 +111,12 @@ class GameGenie { return { value, addr, wantskey, key }; } - encodeHex(addr, value, key, wantskey) { + encodeHex( + addr: number, + value: number, + key?: number, + wantskey?: boolean, + ): string { let s = toHex(addr, 4) + ":" + toHex(value, 2); if (key !== undefined || wantskey) { @@ -114,7 +130,7 @@ class GameGenie { return s; } - decodeHex(s) { + decodeHex(s: string): GameGeniePatch | null { const match = s.match(/([0-9a-fA-F]+):([0-9a-fA-F]+)(\?[0-9a-fA-F]*)?/); if (!match) return null; @@ -129,8 +145,13 @@ class GameGenie { return { value, addr, wantskey, key }; } - encode(addr, value, key, wantskey) { - const digits = Array(6); + encode( + addr: number, + value: number, + key?: number, + wantskey?: boolean, + ): string { + const digits: number[] = Array(6); digits[0] = (value & 7) + ((value >> 4) & 8); digits[1] = ((value >> 4) & 7) + ((addr >> 4) & 8); diff --git a/src/index.js b/src/index.js deleted file mode 100644 index fb622508..00000000 --- a/src/index.js +++ /dev/null @@ -1,6 +0,0 @@ -import Browser from "./browser/index.js"; -import Controller from "./controller.js"; -import GameGenie from "./gamegenie.js"; -import NES from "./nes.js"; - -export { Browser, Controller, GameGenie, NES }; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 00000000..5d19c0ed --- /dev/null +++ b/src/index.ts @@ -0,0 +1,11 @@ +import Browser from "./browser/index.ts"; +import Controller from "./controller.ts"; +import GameGenie from "./gamegenie.ts"; +import NES from "./nes.ts"; + +export { Browser, Controller, GameGenie, NES }; + +export type { BrowserOptions } from "./browser/index.ts"; +export type { ButtonKey, ControllerState } from "./controller.ts"; +export type { GameGeniePatch } from "./gamegenie.ts"; +export type { ControllerId, EmulatorData, NESOptions, RomData } from "./nes.ts"; diff --git a/src/mappers/index.js b/src/mappers/index.js deleted file mode 100644 index b5e94b2d..00000000 --- a/src/mappers/index.js +++ /dev/null @@ -1,45 +0,0 @@ -import Mapper0 from "./mapper0.js"; -import Mapper1 from "./mapper1.js"; -import Mapper2 from "./mapper2.js"; -import Mapper3 from "./mapper3.js"; -import Mapper4 from "./mapper4.js"; -import Mapper5 from "./mapper5.js"; -import Mapper7 from "./mapper7.js"; -import Mapper9 from "./mapper9.js"; -import Mapper11 from "./mapper11.js"; -import Mapper34 from "./mapper34.js"; -import Mapper38 from "./mapper38.js"; -import Mapper66 from "./mapper66.js"; -import Mapper71 from "./mapper71.js"; -import Mapper79 from "./mapper79.js"; -import Mapper94 from "./mapper94.js"; -import Mapper118 from "./mapper118.js"; -import Mapper119 from "./mapper119.js"; -import Mapper140 from "./mapper140.js"; -import Mapper180 from "./mapper180.js"; -import Mapper240 from "./mapper240.js"; -import Mapper241 from "./mapper241.js"; - -export default { - 0: Mapper0, - 1: Mapper1, - 2: Mapper2, - 3: Mapper3, - 4: Mapper4, - 5: Mapper5, - 7: Mapper7, - 9: Mapper9, - 11: Mapper11, - 34: Mapper34, - 38: Mapper38, - 66: Mapper66, - 71: Mapper71, - 79: Mapper79, - 94: Mapper94, - 118: Mapper118, - 119: Mapper119, - 140: Mapper140, - 180: Mapper180, - 240: Mapper240, - 241: Mapper241, -}; diff --git a/src/mappers/index.ts b/src/mappers/index.ts new file mode 100644 index 00000000..ea51b2c0 --- /dev/null +++ b/src/mappers/index.ts @@ -0,0 +1,46 @@ +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; +import Mapper1 from "./mapper1.ts"; +import Mapper2 from "./mapper2.ts"; +import Mapper3 from "./mapper3.ts"; +import Mapper4 from "./mapper4.ts"; +import Mapper5 from "./mapper5.ts"; +import Mapper7 from "./mapper7.ts"; +import Mapper9 from "./mapper9.ts"; +import Mapper11 from "./mapper11.ts"; +import Mapper34 from "./mapper34.ts"; +import Mapper38 from "./mapper38.ts"; +import Mapper66 from "./mapper66.ts"; +import Mapper71 from "./mapper71.ts"; +import Mapper79 from "./mapper79.ts"; +import Mapper94 from "./mapper94.ts"; +import Mapper118 from "./mapper118.ts"; +import Mapper119 from "./mapper119.ts"; +import Mapper140 from "./mapper140.ts"; +import Mapper180 from "./mapper180.ts"; +import Mapper240 from "./mapper240.ts"; +import Mapper241 from "./mapper241.ts"; + +export default { + 0: Mapper0, + 1: Mapper1, + 2: Mapper2, + 3: Mapper3, + 4: Mapper4, + 5: Mapper5, + 7: Mapper7, + 9: Mapper9, + 11: Mapper11, + 34: Mapper34, + 38: Mapper38, + 66: Mapper66, + 71: Mapper71, + 79: Mapper79, + 94: Mapper94, + 118: Mapper118, + 119: Mapper119, + 140: Mapper140, + 180: Mapper180, + 240: Mapper240, + 241: Mapper241, +}; diff --git a/src/mappers/mapper0.js b/src/mappers/mapper0.ts similarity index 99% rename from src/mappers/mapper0.js rename to src/mappers/mapper0.ts index cd372e4b..92856175 100755 --- a/src/mappers/mapper0.js +++ b/src/mappers/mapper0.ts @@ -1,4 +1,5 @@ -import { copyArrayElements } from "../utils.js"; +// @ts-nocheck +import { copyArrayElements } from "../utils.ts"; // NROM - the simplest NES cartridge board (NES-NROM-128/NROM-256) // Used by games like Super Mario Bros., Donkey Kong, Excitebike. diff --git a/src/mappers/mapper1.js b/src/mappers/mapper1.ts similarity index 99% rename from src/mappers/mapper1.js rename to src/mappers/mapper1.ts index 47e52436..262a64c5 100644 --- a/src/mappers/mapper1.js +++ b/src/mappers/mapper1.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // MMC1 / SxROM (SKROM, SLROM, SNROM, etc.) // Used by games like The Legend of Zelda, Metroid, Mega Man 2, Final Fantasy. diff --git a/src/mappers/mapper11.js b/src/mappers/mapper11.ts similarity index 95% rename from src/mappers/mapper11.js rename to src/mappers/mapper11.ts index bbdb20ff..c387462a 100644 --- a/src/mappers/mapper11.js +++ b/src/mappers/mapper11.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // Color Dreams (unlicensed discrete mapper) // Used by games like Bible Adventures, Crystal Mines, Chiller, Metal Fighter. diff --git a/src/mappers/mapper118.js b/src/mappers/mapper118.ts similarity index 98% rename from src/mappers/mapper118.js rename to src/mappers/mapper118.ts index dffaa241..3af2567a 100644 --- a/src/mappers/mapper118.js +++ b/src/mappers/mapper118.ts @@ -1,4 +1,5 @@ -import Mapper4 from "./mapper4.js"; +// @ts-nocheck +import Mapper4 from "./mapper4.ts"; // TxSROM - MMC3 variant with CHR-controlled nametable mirroring // Used by games like Armadillo, Pro Sport Hockey, Goal! Two. diff --git a/src/mappers/mapper119.js b/src/mappers/mapper119.ts similarity index 98% rename from src/mappers/mapper119.js rename to src/mappers/mapper119.ts index 0dcb4dca..df2b8880 100644 --- a/src/mappers/mapper119.js +++ b/src/mappers/mapper119.ts @@ -1,6 +1,7 @@ -import Mapper4 from "./mapper4.js"; -import Tile from "../tile.js"; -import { copyArrayElements } from "../utils.js"; +// @ts-nocheck +import Mapper4 from "./mapper4.ts"; +import Tile from "../tile.ts"; +import { copyArrayElements } from "../utils.ts"; // TQROM - MMC3 variant that supports both CHR ROM and CHR RAM simultaneously. // Used by Pin-Bot and High Speed (both by Rare). diff --git a/src/mappers/mapper140.js b/src/mappers/mapper140.ts similarity index 94% rename from src/mappers/mapper140.js rename to src/mappers/mapper140.ts index 38c64931..e7212a1c 100644 --- a/src/mappers/mapper140.js +++ b/src/mappers/mapper140.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // Jaleco JF-11 / JF-14 // Used by Bio Senshi Dan - Increaser Tono Tatakai. diff --git a/src/mappers/mapper180.js b/src/mappers/mapper180.ts similarity index 95% rename from src/mappers/mapper180.js rename to src/mappers/mapper180.ts index c3d96a6b..ed8c11f3 100644 --- a/src/mappers/mapper180.js +++ b/src/mappers/mapper180.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // UNROM (AND-logic variant, HVC-UNROM) // Used by Crazy Climber. diff --git a/src/mappers/mapper2.js b/src/mappers/mapper2.ts similarity index 95% rename from src/mappers/mapper2.js rename to src/mappers/mapper2.ts index 2264f04d..c90a2841 100644 --- a/src/mappers/mapper2.js +++ b/src/mappers/mapper2.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // UxROM (NES-UNROM, NES-UOROM) // Used by games like Mega Man, Castlevania, Contra, Duck Tales, Metal Gear. diff --git a/src/mappers/mapper240.js b/src/mappers/mapper240.ts similarity index 93% rename from src/mappers/mapper240.js rename to src/mappers/mapper240.ts index 77e03940..040908a1 100644 --- a/src/mappers/mapper240.js +++ b/src/mappers/mapper240.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // Mapper 240 (Jing Ke Xin Zhuan / Sheng Huo Lie Zhuan PCBs) // Used by Jing Ke Xin Zhuan, Sheng Huo Lie Zhuan. diff --git a/src/mappers/mapper241.js b/src/mappers/mapper241.ts similarity index 92% rename from src/mappers/mapper241.js rename to src/mappers/mapper241.ts index 4dd96102..b24548ae 100644 --- a/src/mappers/mapper241.js +++ b/src/mappers/mapper241.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // BxROM variant (Hengge Technology) // Used by various Hengge Technology titles and educational cartridges. diff --git a/src/mappers/mapper3.js b/src/mappers/mapper3.ts similarity index 93% rename from src/mappers/mapper3.js rename to src/mappers/mapper3.ts index f9956da8..9dc0e134 100644 --- a/src/mappers/mapper3.js +++ b/src/mappers/mapper3.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // CNROM // Used by games like Solomon's Key, Arkanoid, Arkista's Ring, Bump 'n' Jump. diff --git a/src/mappers/mapper34.js b/src/mappers/mapper34.ts similarity index 92% rename from src/mappers/mapper34.js rename to src/mappers/mapper34.ts index fff2242b..c6b230fd 100644 --- a/src/mappers/mapper34.js +++ b/src/mappers/mapper34.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // BNROM (NES-BNROM) // Used by games like Deadly Towers (Mashou), Darkseed. diff --git a/src/mappers/mapper38.js b/src/mappers/mapper38.ts similarity index 93% rename from src/mappers/mapper38.js rename to src/mappers/mapper38.ts index b75d42c1..a3a78563 100644 --- a/src/mappers/mapper38.js +++ b/src/mappers/mapper38.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // PCI556 (UNL-PCI556) - Bit Corp // Used by Crime Busters. diff --git a/src/mappers/mapper4.js b/src/mappers/mapper4.ts similarity index 99% rename from src/mappers/mapper4.js rename to src/mappers/mapper4.ts index b45455fc..0abe6f34 100644 --- a/src/mappers/mapper4.js +++ b/src/mappers/mapper4.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // MMC3 / TxROM (TSROM, TLSROM, TQROM, etc.) // Used by games like Super Mario Bros. 2, Super Mario Bros. 3, Kirby's Adventure. diff --git a/src/mappers/mapper5.js b/src/mappers/mapper5.ts similarity index 99% rename from src/mappers/mapper5.js rename to src/mappers/mapper5.ts index 6a07914e..96aa1a66 100644 --- a/src/mappers/mapper5.js +++ b/src/mappers/mapper5.ts @@ -1,5 +1,6 @@ -import Mapper0 from "./mapper0.js"; -import { copyArrayElements } from "../utils.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; +import { copyArrayElements } from "../utils.ts"; // MMC5 / ExROM (EKROM, ELROM, ETROM, EWROM) // Used by games like Castlevania III, Just Breed, Uncharted Waters, Metal Slader Glory. diff --git a/src/mappers/mapper66.js b/src/mappers/mapper66.ts similarity index 93% rename from src/mappers/mapper66.js rename to src/mappers/mapper66.ts index 286d8ec5..23c3ca69 100644 --- a/src/mappers/mapper66.js +++ b/src/mappers/mapper66.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // GxROM (NES-GNROM, NES-MHROM) // Used by games like Doraemon, Dragon Power, Gumshoe, Super Mario Bros. + Duck Hunt. diff --git a/src/mappers/mapper7.js b/src/mappers/mapper7.ts similarity index 95% rename from src/mappers/mapper7.js rename to src/mappers/mapper7.ts index b627be60..8fe80fb3 100644 --- a/src/mappers/mapper7.js +++ b/src/mappers/mapper7.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // AxROM (NES-AMROM, NES-ANROM, NES-AOROM) // Used by games like Battletoads, Marble Madness, Wizards & Warriors. diff --git a/src/mappers/mapper71.js b/src/mappers/mapper71.ts similarity index 96% rename from src/mappers/mapper71.js rename to src/mappers/mapper71.ts index 3a54c4df..ee92a326 100644 --- a/src/mappers/mapper71.js +++ b/src/mappers/mapper71.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // Camerica/Codemasters mapper (BF9093/BF9097) // Used by games like Fire Hawk, Micro Machines, Bee 52, MiG 29, etc. diff --git a/src/mappers/mapper79.js b/src/mappers/mapper79.ts similarity index 95% rename from src/mappers/mapper79.js rename to src/mappers/mapper79.ts index f4d8de1d..2d052526 100644 --- a/src/mappers/mapper79.js +++ b/src/mappers/mapper79.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // NINA-03/NINA-06 (American Video Entertainment) // Used by games like Tiles of Fate, Krazy Kreatures, Impossible Mission II. diff --git a/src/mappers/mapper9.js b/src/mappers/mapper9.ts similarity index 99% rename from src/mappers/mapper9.js rename to src/mappers/mapper9.ts index 491dca41..d5b077cb 100644 --- a/src/mappers/mapper9.js +++ b/src/mappers/mapper9.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // MMC2 (PNROM / PEEOROM) // Used exclusively by Mike Tyson's Punch-Out!! (and Punch-Out!!). diff --git a/src/mappers/mapper94.js b/src/mappers/mapper94.ts similarity index 95% rename from src/mappers/mapper94.js rename to src/mappers/mapper94.ts index 193fd543..dff5caf4 100644 --- a/src/mappers/mapper94.js +++ b/src/mappers/mapper94.ts @@ -1,4 +1,5 @@ -import Mapper0 from "./mapper0.js"; +// @ts-nocheck +import Mapper0 from "./mapper0.ts"; // UN1ROM (HVC-UN1ROM) // Used by Senjou no Ookami (Commando). diff --git a/src/nes.d.ts b/src/nes.d.ts deleted file mode 100644 index 0c25b666..00000000 --- a/src/nes.d.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { ButtonKey } from "./controller"; -import { GameGenie } from "./gamegenie"; - -export type ControllerId = 1 | 2; - -export interface EmulatorData { - cpu: object; - mmap: object; - ppu: object; - papu: object; -} - -export interface NESOptions { - onFrame?: (buffer: Uint32Array) => void; - onAudioSample?: (left: number, right: number) => void; - onStatusUpdate?: (status: string) => void; - onBatteryRamWrite?: (address: number, value: number) => void; - emulateSound?: boolean; - sampleRate?: number; -} - -export class NES { - constructor(opts: NESOptions); - gameGenie: GameGenie; - reset: () => void; - frame: () => void; - buttonDown: (controller: ControllerId, button: ButtonKey) => void; - buttonUp: (controller: ControllerId, button: ButtonKey) => void; - zapperMove: (x: number, y: number) => void; - zapperFireDown: () => void; - zapperFireUp: () => void; - getFPS: () => number; - reloadROM: () => void; - loadROM: (data: string | Buffer | Uint8Array | ArrayBuffer) => void; - setFramerate: (rate: number) => void; - toJSON: () => EmulatorData; - fromJSON: (data: EmulatorData) => void; -} diff --git a/src/nes.js b/src/nes.ts similarity index 63% rename from src/nes.js rename to src/nes.ts index 7b2aecf9..c1863719 100644 --- a/src/nes.js +++ b/src/nes.ts @@ -1,12 +1,68 @@ -import CPU from "./cpu.js"; -import Controller from "./controller.js"; -import PPU from "./ppu/index.js"; -import PAPU from "./papu/index.js"; -import GameGenie from "./gamegenie.js"; -import ROM from "./rom.js"; +import CPU from "./cpu.ts"; +import Controller from "./controller.ts"; +import type { ButtonKey, ControllerState } from "./controller.ts"; +import PPU from "./ppu/index.ts"; +import PAPU from "./papu/index.ts"; +import GameGenie from "./gamegenie.ts"; +import ROM from "./rom.ts"; + +export type ControllerId = 1 | 2; + +export type RomData = string | Uint8Array | ArrayBuffer; + +export interface NESOptions { + /** Called at the end of each frame with a 256×240 pixel buffer (Uint32Array of ARGB values). */ + onFrame?: (buffer: Uint32Array) => void; + /** Called for each audio sample with left/right channel values (-1.0 to 1.0). */ + onAudioSample?: ((left: number, right: number) => void) | null; + /** Called with status messages (e.g. "Ready to load a ROM."). */ + onStatusUpdate?: (status: string) => void; + /** Called when battery-backed SRAM is written. Use this to persist save data. */ + onBatteryRamWrite?: (address: number, value: number) => void; + /** Enable/disable audio emulation. Default: true. */ + emulateSound?: boolean; + /** Audio sample rate in Hz. Default: 48000. */ + sampleRate?: number; +} + +type ResolvedOptions = Required> & { + onAudioSample: ((left: number, right: number) => void) | null; +}; + +export interface EmulatorData { + cpu: object; + mmap: object; + ppu: object; + papu: object; + controllers?: { 1: ControllerState; 2: ControllerState }; +} + +// The internal emulator components (CPU, PPU, PAPU, mappers) still have +// @ts-nocheck for a loose conversion, so their public shapes are not yet +// accurately typed. We use `any` for their field types here so consumers +// of the NES class still get precise types on the public API surface. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Internal = any; class NES { - constructor(opts) { + opts: ResolvedOptions; + ui: { + writeFrame: (buffer: Uint32Array) => void; + updateStatus: (status: string) => void; + }; + cpu: Internal; + ppu: Internal; + papu: Internal; + gameGenie: GameGenie; + mmap: Internal; + rom: Internal; + controllers: { 1: Controller; 2: Controller }; + fpsFrameCount: number; + lastFpsTime: number | null; + romData: RomData | null; + crashed: boolean; + + constructor(opts: NESOptions) { this.opts = { onFrame: function () {}, onAudioSample: null, @@ -35,13 +91,15 @@ class NES { }; this.fpsFrameCount = 0; + this.lastFpsTime = null; this.romData = null; + this.crashed = false; this.ui.updateStatus("Ready to load a ROM."); } // Resets the system - reset() { + reset(): void { this.cpu = new CPU(this); this.ppu = new PPU(this); this.papu = new PAPU(this); @@ -59,7 +117,7 @@ class NES { // The frame loop. PPU is advanced inline after every CPU bus operation // (in cpu.load/write/push/pull). APU is clocked in bulk after each // instruction for compatibility with its sample timing logic. - frame = () => { + frame = (): void => { if (this.crashed) { throw new Error( "Game has crashed. Call reset() or loadROM() to restart.", @@ -112,33 +170,33 @@ class NES { this.fpsFrameCount++; }; - buttonDown = (controller, button) => { + buttonDown = (controller: ControllerId, button: ButtonKey): void => { this.controllers[controller].buttonDown(button); }; - buttonUp = (controller, button) => { + buttonUp = (controller: ControllerId, button: ButtonKey): void => { this.controllers[controller].buttonUp(button); }; - zapperMove = (x, y) => { + zapperMove = (x: number, y: number): void => { if (!this.mmap) return; this.mmap.zapperX = x; this.mmap.zapperY = y; }; - zapperFireDown = () => { + zapperFireDown = (): void => { if (!this.mmap) return; this.mmap.zapperFired = true; }; - zapperFireUp = () => { + zapperFireUp = (): void => { if (!this.mmap) return; this.mmap.zapperFired = false; }; - getFPS() { + getFPS(): number | null { const now = Date.now(); - let fps = null; + let fps: number | null = null; if (this.lastFpsTime) { fps = this.fpsFrameCount / ((now - this.lastFpsTime) / 1000); } @@ -147,7 +205,7 @@ class NES { return fps; } - reloadROM() { + reloadROM(): void { if (this.romData !== null) { this.loadROM(this.romData); } @@ -155,7 +213,7 @@ class NES { // Loads a ROM file into the CPU and PPU. // The ROM file is validated first. - loadROM(data) { + loadROM(data: RomData): void { // Load ROM file: this.rom = new ROM(this); this.rom.load(data); @@ -171,11 +229,11 @@ class NES { // default 60fps each frame() produces ~800 samples at 48kHz. If the host // calls frame() less often (e.g. 30fps), the sample timer must fire more // frequently per CPU cycle so each frame still fills the audio buffer. - setFramerate(rate) { + setFramerate(rate: number): void { this.papu.setFrameRate(rate); } - toJSON() { + toJSON(): EmulatorData { return { // romData: this.romData, cpu: this.cpu.toJSON(), @@ -189,7 +247,7 @@ class NES { }; } - fromJSON(s) { + fromJSON(s: EmulatorData): void { this.reset(); // this.romData = s.romData; this.cpu.fromJSON(s.cpu); diff --git a/src/papu/channel-dm.js b/src/papu/channel-dm.ts similarity index 99% rename from src/papu/channel-dm.js rename to src/papu/channel-dm.ts index 0d03d55c..6e442b73 100644 --- a/src/papu/channel-dm.js +++ b/src/papu/channel-dm.ts @@ -1,4 +1,5 @@ -import { fromJSON, toJSON } from "../utils.js"; +// @ts-nocheck +import { fromJSON, toJSON } from "../utils.ts"; class ChannelDM { static MODE_NORMAL = 0; diff --git a/src/papu/channel-noise.js b/src/papu/channel-noise.ts similarity index 98% rename from src/papu/channel-noise.js rename to src/papu/channel-noise.ts index ab20f234..ae5574c7 100644 --- a/src/papu/channel-noise.js +++ b/src/papu/channel-noise.ts @@ -1,4 +1,5 @@ -import { fromJSON, toJSON } from "../utils.js"; +// @ts-nocheck +import { fromJSON, toJSON } from "../utils.ts"; class ChannelNoise { constructor(papu) { diff --git a/src/papu/channel-square.js b/src/papu/channel-square.ts similarity index 98% rename from src/papu/channel-square.js rename to src/papu/channel-square.ts index 5ecab215..7d3d94e2 100644 --- a/src/papu/channel-square.js +++ b/src/papu/channel-square.ts @@ -1,4 +1,5 @@ -import { fromJSON, toJSON } from "../utils.js"; +// @ts-nocheck +import { fromJSON, toJSON } from "../utils.ts"; class ChannelSquare { constructor(papu, square1) { diff --git a/src/papu/channel-triangle.js b/src/papu/channel-triangle.ts similarity index 98% rename from src/papu/channel-triangle.js rename to src/papu/channel-triangle.ts index bda42a1f..8e227646 100644 --- a/src/papu/channel-triangle.js +++ b/src/papu/channel-triangle.ts @@ -1,4 +1,5 @@ -import { fromJSON, toJSON } from "../utils.js"; +// @ts-nocheck +import { fromJSON, toJSON } from "../utils.ts"; class ChannelTriangle { constructor(papu) { diff --git a/src/papu/index.js b/src/papu/index.ts similarity index 99% rename from src/papu/index.js rename to src/papu/index.ts index 718a4bbf..0745f7a2 100644 --- a/src/papu/index.js +++ b/src/papu/index.ts @@ -1,8 +1,9 @@ -import { fromJSON, toJSON } from "../utils.js"; -import ChannelDM from "./channel-dm.js"; -import ChannelNoise from "./channel-noise.js"; -import ChannelSquare from "./channel-square.js"; -import ChannelTriangle from "./channel-triangle.js"; +// @ts-nocheck +import { fromJSON, toJSON } from "../utils.ts"; +import ChannelDM from "./channel-dm.ts"; +import ChannelNoise from "./channel-noise.ts"; +import ChannelSquare from "./channel-square.ts"; +import ChannelTriangle from "./channel-triangle.ts"; const CPU_FREQ_NTSC = 1789772.5; //1789772.72727272d; // const CPU_FREQ_PAL = 1773447.4; diff --git a/src/ppu/index.js b/src/ppu/index.ts similarity index 99% rename from src/ppu/index.js rename to src/ppu/index.ts index a69b917d..0f398fc1 100644 --- a/src/ppu/index.js +++ b/src/ppu/index.ts @@ -1,7 +1,8 @@ -import Tile from "../tile.js"; -import { fromJSON, toJSON } from "../utils.js"; -import NameTable from "./nametable.js"; -import PaletteTable from "./palette-table.js"; +// @ts-nocheck +import Tile from "../tile.ts"; +import { fromJSON, toJSON } from "../utils.ts"; +import NameTable from "./nametable.ts"; +import PaletteTable from "./palette-table.ts"; class PPU { // Status flags: diff --git a/src/ppu/nametable.js b/src/ppu/nametable.ts similarity index 98% rename from src/ppu/nametable.js rename to src/ppu/nametable.ts index 04dc9213..e6d48c41 100644 --- a/src/ppu/nametable.js +++ b/src/ppu/nametable.ts @@ -1,3 +1,4 @@ +// @ts-nocheck class NameTable { constructor(width, height, name) { this.width = width; diff --git a/src/ppu/palette-table.js b/src/ppu/palette-table.ts similarity index 99% rename from src/ppu/palette-table.js rename to src/ppu/palette-table.ts index 5241c125..821db383 100644 --- a/src/ppu/palette-table.js +++ b/src/ppu/palette-table.ts @@ -1,3 +1,4 @@ +// @ts-nocheck class PaletteTable { constructor() { this.curTable = new Uint32Array(64); diff --git a/src/rom.js b/src/rom.ts similarity index 98% rename from src/rom.js rename to src/rom.ts index ec455230..ff99ea54 100644 --- a/src/rom.js +++ b/src/rom.ts @@ -1,5 +1,6 @@ -import Mappers from "./mappers/index.js"; -import Tile from "./tile.js"; +// @ts-nocheck +import Mappers from "./mappers/index.ts"; +import Tile from "./tile.ts"; class ROM { // Mirroring types (instance properties so they're accessible via diff --git a/src/tile.js b/src/tile.ts similarity index 99% rename from src/tile.js rename to src/tile.ts index cb0db35a..3b4d54dc 100644 --- a/src/tile.js +++ b/src/tile.ts @@ -1,3 +1,4 @@ +// @ts-nocheck class Tile { constructor() { // Tile data: color indices 0–3 diff --git a/src/utils.js b/src/utils.ts similarity index 98% rename from src/utils.js rename to src/utils.ts index 35a5a808..12bfbca1 100644 --- a/src/utils.js +++ b/src/utils.ts @@ -1,3 +1,4 @@ +// @ts-nocheck export function copyArrayElements(src, srcPos, dest, destPos, length) { for (let i = 0; i < length; ++i) { dest[destPos + i] = src[srcPos + i]; diff --git a/test/accuracycoin.spec.js b/test/accuracycoin.spec.js index 028e8437..b5c29d0f 100644 --- a/test/accuracycoin.spec.js +++ b/test/accuracycoin.spec.js @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import { describe, it, before, after } from "node:test"; import fs from "fs"; -import NES from "../src/nes.js"; -import Controller from "../src/controller.js"; +import NES from "../src/nes.ts"; +import Controller from "../src/controller.ts"; // AccuracyCoin test result memory addresses and test names, organized by page. // diff --git a/test/cpu.spec.js b/test/cpu.spec.js index 081eb185..7d1c3b1c 100644 --- a/test/cpu.spec.js +++ b/test/cpu.spec.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { describe, it, beforeEach } from "node:test"; -import CPU from "../src/cpu.js"; +import CPU from "../src/cpu.ts"; // Based on https://github.com/gutomaia/wedNESday/blob/0.0.x/wednesday/cpu_6502_spec.py // ... which was based on https://github.com/nwidger/nintengo/blob/master/m65go2/instructions_test.go @@ -45,7 +45,7 @@ MMAP.prototype.write = function (addr, val) { this.mem[addr] = val; }; -import GameGenie from "../src/gamegenie.js"; +import GameGenie from "../src/gamegenie.ts"; const NES = function (mmap) { this.mmap = mmap; diff --git a/test/gamegenie.spec.js b/test/gamegenie.spec.js index 38829629..ca5670f0 100644 --- a/test/gamegenie.spec.js +++ b/test/gamegenie.spec.js @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { describe, it, beforeEach } from "node:test"; -import GameGenie from "../src/gamegenie.js"; -import NES from "../src/nes.js"; +import GameGenie from "../src/gamegenie.ts"; +import NES from "../src/nes.ts"; import fs from "fs"; describe("GameGenie", function () { diff --git a/test/mappers.spec.js b/test/mappers.spec.js index 89789401..520a0c60 100644 --- a/test/mappers.spec.js +++ b/test/mappers.spec.js @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import { describe, it, beforeEach } from "node:test"; -import Mappers from "../src/mappers/index.js"; -import NameTable from "../src/ppu/nametable.js"; -import Tile from "../src/tile.js"; +import Mappers from "../src/mappers/index.ts"; +import NameTable from "../src/ppu/nametable.ts"; +import Tile from "../src/tile.ts"; // Create a minimal mock NES sufficient for Mapper 0 function createMockNes() { diff --git a/test/mmc5-chr.spec.js b/test/mmc5-chr.spec.js index 43625f8a..a1dcdd57 100644 --- a/test/mmc5-chr.spec.js +++ b/test/mmc5-chr.spec.js @@ -1,8 +1,8 @@ import assert from "node:assert/strict"; import { describe, it, beforeEach } from "node:test"; -import Mappers from "../src/mappers/index.js"; -import NameTable from "../src/ppu/nametable.js"; -import Tile from "../src/tile.js"; +import Mappers from "../src/mappers/index.ts"; +import NameTable from "../src/ppu/nametable.ts"; +import Tile from "../src/tile.ts"; // MMC5 CHR Bank Switching Test Harness // diff --git a/test/nes.spec.js b/test/nes.spec.js index 2e82b2f6..ffa16f4e 100644 --- a/test/nes.spec.js +++ b/test/nes.spec.js @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { describe, it, before, mock } from "node:test"; import fs from "fs"; -import NES from "../src/nes.js"; +import NES from "../src/nes.ts"; describe("NES", function () { it("can be initialized", function () { diff --git a/test/nestest.spec.js b/test/nestest.spec.js index f2d193d9..769efb10 100644 --- a/test/nestest.spec.js +++ b/test/nestest.spec.js @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { describe, it, before, after } from "node:test"; import fs from "fs"; -import NES from "../src/nes.js"; +import NES from "../src/nes.ts"; // Error code descriptions from nestest.txt, keyed by [byte, code]. // Byte 0x02 errors: diff --git a/test/rom.spec.js b/test/rom.spec.js index 3cf34bf2..f3a95092 100644 --- a/test/rom.spec.js +++ b/test/rom.spec.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import ROM from "../src/rom.js"; +import ROM from "../src/rom.ts"; // Build a minimal iNES/NES 2.0 header as a Uint8Array. // The returned array includes enough PRG/CHR data bytes (filled with 0) diff --git a/test/typedefs.spec.js b/test/typedefs.spec.js deleted file mode 100644 index 34d86f07..00000000 --- a/test/typedefs.spec.js +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Runtime validation that TypeScript .d.ts declarations match the actual - * JavaScript implementation. This catches drift like declaring a method - * that doesn't exist (e.g. stop()) or missing a method that was added. - * - * The test parses the .d.ts files to extract declared class members, then - * checks them against the real classes. - */ -import assert from "node:assert/strict"; -import { describe, it } from "node:test"; -import fs from "fs"; -import NES from "../src/nes.js"; -import Controller from "../src/controller.js"; - -/** - * Parse a .d.ts file and extract declared members of a given class. - * Returns { methods: string[], properties: string[], staticMembers: string[] }. - */ -function parseDtsClass(filePath, className) { - const src = fs.readFileSync(filePath, "utf-8"); - - // Find the class block - const classRegex = new RegExp( - `export\\s+class\\s+${className}\\s*\\{([\\s\\S]*?)\\n\\}`, - ); - const match = src.match(classRegex); - if (!match) { - throw new Error(`Class ${className} not found in ${filePath}`); - } - const body = match[1]; - - const methods = []; - const properties = []; - const staticMembers = []; - - for (const line of body.split("\n")) { - const trimmed = line.trim(); - // Skip empty lines, comments, and constructor - if ( - !trimmed || - trimmed.startsWith("//") || - trimmed.startsWith("/*") || - trimmed.startsWith("*") || - trimmed.startsWith("constructor") - ) { - continue; - } - - // Static members: "static readonly BUTTON_A = 0;" - const staticMatch = trimmed.match(/^static\s+(?:readonly\s+)?(\w+)/); - if (staticMatch) { - staticMembers.push(staticMatch[1]); - continue; - } - - // Method or arrow-function property: "name: (...) => type" or "name(...): type" - const memberMatch = trimmed.match(/^(\w+)\s*[:(]/); - if (memberMatch) { - const name = memberMatch[0].includes("(") - ? memberMatch[1] // name(...): method syntax - : memberMatch[1]; // name: property/arrow syntax - - // Distinguish methods (has parentheses in signature) from data properties - if (trimmed.includes("=>") || trimmed.match(/^\w+\s*\(/)) { - methods.push(name); - } else { - properties.push(name); - } - continue; - } - } - - return { methods, properties, staticMembers }; -} - -describe("TypeScript definitions match implementation", function () { - describe("NES class (nes.d.ts)", function () { - const dts = parseDtsClass("src/nes.d.ts", "NES"); - - it("every declared method exists on the NES instance", function () { - const nes = new NES({}); - for (const method of dts.methods) { - assert.equal( - typeof nes[method], - "function", - `nes.d.ts declares ${method}() but NES instance has no such method`, - ); - } - }); - - it("NES instance has no public methods missing from declarations", function () { - const nes = new NES({}); - const declared = new Set([...dts.methods, "constructor"]); - // Collect methods from prototype and own properties (arrow functions) - const actual = new Set(); - // Prototype methods - for (const name of Object.getOwnPropertyNames( - Object.getPrototypeOf(nes), - )) { - if (typeof nes[name] === "function") actual.add(name); - } - // Own properties that are functions (arrow function class fields) - for (const name of Object.getOwnPropertyNames(nes)) { - if (typeof nes[name] === "function") actual.add(name); - } - - for (const method of actual) { - // Skip private/internal members (prefixed with _) - if (method.startsWith("_")) continue; - assert.ok( - declared.has(method), - `NES has method ${method}() but it is not declared in nes.d.ts`, - ); - } - }); - }); - - describe("Controller class (controller.d.ts)", function () { - const dts = parseDtsClass("src/controller.d.ts", "Controller"); - - it("every declared method exists on the Controller instance", function () { - const ctrl = new Controller(); - for (const method of dts.methods) { - assert.equal( - typeof ctrl[method], - "function", - `controller.d.ts declares ${method}() but Controller instance has no such method`, - ); - } - }); - - it("every declared static member exists on the Controller class", function () { - for (const name of dts.staticMembers) { - assert.notEqual( - Controller[name], - undefined, - `controller.d.ts declares static ${name} but Controller.${name} is undefined`, - ); - } - }); - - it("Controller has no public methods missing from declarations", function () { - const ctrl = new Controller(); - const declared = new Set([...dts.methods, "constructor"]); - const actual = new Set(); - for (const name of Object.getOwnPropertyNames( - Object.getPrototypeOf(ctrl), - )) { - if (typeof ctrl[name] === "function") actual.add(name); - } - for (const name of Object.getOwnPropertyNames(ctrl)) { - if (typeof ctrl[name] === "function") actual.add(name); - } - - for (const method of actual) { - if (method.startsWith("_")) continue; - assert.ok( - declared.has(method), - `Controller has method ${method}() but it is not declared in controller.d.ts`, - ); - } - }); - - it("Controller has no public static members missing from declarations", function () { - const declaredStatic = new Set(dts.staticMembers); - for (const name of Object.getOwnPropertyNames(Controller)) { - // Skip standard static properties (prototype, length, name) - if (["prototype", "length", "name"].includes(name)) continue; - if (name.startsWith("_")) continue; - assert.ok( - declaredStatic.has(name), - `Controller has static ${name} but it is not declared in controller.d.ts`, - ); - } - }); - }); -}); diff --git a/tsconfig.json b/tsconfig.json index 670d34f8..dac7bfe5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,15 +1,19 @@ { "compilerOptions": { - "allowJs": true, - "checkJs": false, + "target": "es2022", + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, "noEmit": true, - "skipLibCheck": true, - "module": "node16", - "moduleResolution": "node16", "esModuleInterop": true, - "target": "es2015", - "lib": ["es2015", "dom"] + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "strict": false, + "noImplicitAny": false, + "strictNullChecks": false, + "useDefineForClassFields": true, + "lib": ["es2022", "dom"] }, - "include": ["src/**/*.d.ts", "index.d.ts"], - "exclude": ["node_modules", "dist", "test"] + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] } diff --git a/webpack.config.js b/webpack.config.js index 4883fbbf..23cd2876 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,11 +1,10 @@ import path from "path"; import TerserPlugin from "terser-webpack-plugin"; -import ESLintPlugin from "eslint-webpack-plugin"; export default { entry: { - jsnes: "./src/index.js", - "jsnes.min": "./src/index.js", + jsnes: "./src/index.ts", + "jsnes.min": "./src/index.ts", }, mode: "production", devtool: "source-map", @@ -18,7 +17,26 @@ export default { umdNamedDefine: true, clean: true, }, - module: {}, + resolve: { + extensions: [".ts", ".js"], + extensionAlias: { + ".ts": [".ts", ".js"], + }, + }, + module: { + rules: [ + { + test: /\.ts$/, + exclude: /node_modules/, + use: { + loader: "ts-loader", + options: { + transpileOnly: true, + }, + }, + }, + ], + }, optimization: { minimize: true, minimizer: [ @@ -28,10 +46,5 @@ export default { }), ], }, - plugins: [ - new ESLintPlugin({ - extensions: ["js"], - exclude: "node_modules", - }), - ], + plugins: [], };