diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b6781610b5129deb7a989758348270ba76925b..b7032949cc9f5cbb21bf87549169a79cbe21f5d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added * Support for SIMD based color space conversion - [#45](https://gitlab.stage.hive.pt/joamag/boytacean/-/issues/45) +* Support for `window.requestAnimationFrame()` and game loop inversion of control - [#26](https://gitlab.stage.hive.pt/joamag/boytacean/-/issues/26) ### Changed diff --git a/frontends/web/index.ts b/frontends/web/index.ts index cd0defddda0d2b63119a0cd5146a59fb356b6876..ded99b96dd4f0da56c71f6c0405df328c887448f 100644 --- a/frontends/web/index.ts +++ b/frontends/web/index.ts @@ -58,5 +58,5 @@ const BACKGROUNDS = [ // starts the emulator with the provided ROM URL, this is // going to run the main emulator (infinite) loop - await emulator.main({ romUrl: romUrl }); + await emulator.start({ romUrl: romUrl }); })(); diff --git a/frontends/web/package.json b/frontends/web/package.json index cf92a9ff5c3fff4cab44afae1a6811bb79e671ab..4046de91593cd53c9a700a232d0a7ce542bf65ce 100644 --- a/frontends/web/package.json +++ b/frontends/web/package.json @@ -19,21 +19,21 @@ "source": "index.ts", "devDependencies": { "@parcel/transformer-typescript-tsc": "^2.12.0", - "@types/react": "^18.2.61", - "@types/react-dom": "^18.2.19", - "@typescript-eslint/eslint-plugin": "^7.1.0", - "@typescript-eslint/parser": "^7.1.0", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.13.1", + "@typescript-eslint/parser": "^7.13.1", "buffer": "^6.0.3", - "emukit": "^0.9.8", + "emukit": "^0.10.0", "eslint": "^8.57.0", "jszip": "^3.10.1", - "nodemon": "^3.1.0", + "nodemon": "^3.1.3", "parcel": "^2.12.0", - "prettier": "^3.2.5", + "prettier": "^3.3.2", "process": "^0.11.10", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "typescript": "^5.3.3", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "^5.4.5", "webgl-plot": "^0.7.1" } } diff --git a/frontends/web/ts/gb.ts b/frontends/web/ts/gb.ts index 065f078c083924061f59f2bb701deb1fdbe54c07..8b6b624c7a43210926678a16d032faff65441568 100644 --- a/frontends/web/ts/gb.ts +++ b/frontends/web/ts/gb.ts @@ -8,7 +8,7 @@ import { Compiler, DebugPanel, Emulator, - EmulatorBase, + EmulatorLogic, Entry, Feature, Frequency, @@ -40,7 +40,8 @@ import { Info, PadKey, SaveStateFormat, - StateManager + StateManager, + ClockFrame } from "../lib/boytacean"; import info from "../package.json"; @@ -63,13 +64,6 @@ const LOGIC_HZ = 4194304; */ const VISUAL_HZ = 59.7275; -/** - * The frequency of the pause polling update operation, - * increasing this value will make resume from emulation - * paused state fasted. - */ -const IDLE_HZ = 10; - const DISPLAY_WIDTH = 160; const DISPLAY_HEIGHT = 144; const DISPLAY_SCALE = 2; @@ -81,13 +75,6 @@ const DISPLAY_SCALE = 2; */ const STORE_RATE = 5; -/** - * The sample rate that is going to be used for FPS calculus, - * meaning that every N seconds we will calculate the number - * of frames rendered divided by the N seconds. - */ -const FPS_SAMPLE_RATE = 3; - const KEYS_NAME: Record<string, number> = { ArrowUp: PadKey.Up, ArrowDown: PadKey.Down, @@ -116,7 +103,7 @@ export enum SerialDevice { * and "joins" all the elements together to bring input/output * of the associated machine. */ -export class GameboyEmulator extends EmulatorBase implements Emulator { +export class GameboyEmulator extends EmulatorLogic implements Emulator { /** * The Game Boy engine (probably coming from WASM) that * is going to be used for the emulation. @@ -138,17 +125,17 @@ export class GameboyEmulator extends EmulatorBase implements Emulator { */ private autoMode = false; - private logicFrequency = LOGIC_HZ; - private visualFrequency = VISUAL_HZ; - private idleFrequency = IDLE_HZ; + protected logicFrequency = LOGIC_HZ; + protected visualFrequency = VISUAL_HZ; - private paused = false; - private nextTickTime = 0; - private fps = 0; - private frameStart: number = EmulatorBase.now(); - private frameCount = 0; private paletteIndex = 0; + /** + * Number of pending CPU cycles from the previous tick. + * This is used to keep track of the overflow cycles. + */ + private pending = 0; + /** * The frequency at which the battery backed RAM is going * to be flushed to the `localStorage`. @@ -169,6 +156,12 @@ export class GameboyEmulator extends EmulatorBase implements Emulator { */ private extraSettings: Record<string, string> = {}; + /** + * Current frame structure used in the clocking operations + * of the emulator, allowing deferred frame buffer retrieval. + */ + private clockFrame: ClockFrame | null = null; + constructor(extraSettings = {}) { super(); this.extraSettings = extraSettings; @@ -184,151 +177,47 @@ export class GameboyEmulator extends EmulatorBase implements Emulator { } /** - * Runs the initialization and main loop execution for - * the Game Boy emulator. - * The main execution of this function should be an - * infinite loop running machine `tick` operations. + * Runs a tick operation in the current emulator, this operation should + * be triggered at a regular interval to ensure that the emulator is + * properly updated. * - * @param options The set of options that are going to be - * used in he Game Boy emulator initialization. + * Not necessarily executed once per frame, but rather once per logic + * emulator unit. + * + * The tick operation is responsible for the following operations: + * - Clocks the system by the target number of cycles. + * - Triggers the frame event in case there's a frame to be processed. + * - Triggers the audio event, allowing the deferred retrieval of the audio buffer. + * - Flushes the RAM to the local storage in case the cartridge is battery backed. */ - async main({ romUrl }: { romUrl?: string }) { - // boots the emulator subsystem with the initial - // ROM retrieved from a remote data source - await this.boot({ loadRom: true, romPath: romUrl ?? undefined }); - - // the counter that controls the overflowing cycles - // from tick to tick operation - let pending = 0; - - // runs the sequence as an infinite loop, running - // the associated CPU cycles accordingly - while (true) { - // in case the machine is paused we must delay the execution - // a little bit until the paused state is recovered - if (this.paused) { - await new Promise((resolve) => { - setTimeout(resolve, 1000 / this.idleFrequency); - }); - continue; - } - - // obtains the current time, this value is going - // to be used to compute the need for tick computation - let currentTime = EmulatorBase.now(); - - try { - pending = this.tick( - currentTime, - pending, - Math.round( - (this.logicFrequency * - (this.gameBoy?.multiplier() ?? 1)) / - this.visualFrequency - ) - ); - } catch (err) { - // sets the default error message to be displayed - // to the user, this value may be overridden in case - // a better and more explicit message can be determined - let message = String(err); - - // verifies if the current issue is a panic one - // and updates the message value if that's the case - const messageNormalized = (err as Error).message.toLowerCase(); - const isPanic = - messageNormalized.startsWith("unreachable") || - messageNormalized.startsWith("recursive use of an object"); - if (isPanic) { - message = "Unrecoverable error, restarting Game Boy"; - } - - // displays the error information to both the end-user - // and the developer (for diagnostics) - this.trigger("message", { - text: message, - error: true, - timeout: 5000 - }); - console.error(err); - - // pauses the machine, allowing the end-user to act - // on the error in a proper fashion - this.pause(); - - // if we're talking about a panic, proper action must be taken - // which in this case means restarting both the WASM sub - // system and the machine state (to be able to recover) - if (isPanic) { - await wasm(true); - await this.boot({ restore: false, reuse: false }); - - this.trigger("error"); - } - } - - // calculates the amount of time until the next draw operation - // this is the amount of time that is going to be pending - currentTime = EmulatorBase.now(); - const pendingTime = Math.max(this.nextTickTime - currentTime, 0); - - // waits a little bit for the next frame to be draw, - // this should control the flow of render - await new Promise((resolve) => { - setTimeout(resolve, pendingTime); - }); - } - } - - tick(currentTime: number, pending: number, cycles = 70224) { + tick() { // in case the reference to the system is not set then // returns the control flow immediately (not possible to tick) - if (!this.gameBoy) return pending; - - // in case the time to draw the next frame has not been - // reached the flush of the "tick" logic is skipped - if (currentTime < this.nextTickTime) return pending; + if (!this.gameBoy) return; + + // calculates the number of cycles that are going to be + // processed in the current tick operation, this value is + // calculated using the logic and visual frequencies and + // the current Game Boy multiplier (DMG vs CGB) + const cycles = Math.round( + (this.logicFrequency * (this.gameBoy?.multiplier() ?? 1)) / + this.visualFrequency + ); // initializes the counter of cycles with the pending number // of cycles coming from the previous tick - let counterCycles = pending; - - let lastFrame = this.gameBoy.ppu_frame(); - - while (true) { - // limits the number of cycles to the provided - // cycle value passed as a parameter - if (counterCycles >= cycles) { - break; - } - - // runs the Game Boy clock, this operations should - // include the advance of both the CPU and the PPU - const tickCycles = this.gameBoy.clock(); - counterCycles += tickCycles; - - // in case the frame is different from the previously - // rendered one then it's time to update the canvas - if (this.gameBoy.ppu_frame() !== lastFrame) { - // updates the reference to the last frame index - // to be used for comparison in the next tick - lastFrame = this.gameBoy.ppu_frame(); - - // triggers the frame event indicating that - // a new frame is now available for drawing - this.trigger("frame"); - } - - // in case the current cartridge is battery backed - // then we need to check if a RAM flush to local - // storage operation is required - if (this.cartridge && this.cartridge.has_battery()) { - this.flushCycles -= tickCycles; - if (this.flushCycles <= 0) { - this.saveRam(); - this.flushCycles = this.logicFrequency * STORE_RATE; - } - } + let counterCycles = this.pending; + + // clocks the system by the target number of cycles (deducted + // by the carryover cycles) and then in case there's at least + // a frame to be processed triggers the frame event, allowing + // the deferred retrieval of the frame buffer + this.clockFrame = this.gameBoy.clocks_frame_buffer( + cycles - counterCycles + ); + counterCycles += Number(this.clockFrame.cycles); + if (this.clockFrame.frames > 0) { + this.trigger("frame"); } // triggers the audio event, meaning that the audio should be @@ -336,42 +225,25 @@ export class GameboyEmulator extends EmulatorBase implements Emulator { // the audio buffer that is pending processing this.trigger("audio"); - // increments the number of frames rendered in the current - // section, this value is going to be used to calculate FPS - this.frameCount += 1; - - // in case the target number of frames for FPS control - // has been reached calculates the number of FPS and - // flushes the value to the screen - if (this.frameCount >= this.visualFrequency * FPS_SAMPLE_RATE) { - const currentTime = EmulatorBase.now(); - const deltaTime = (currentTime - this.frameStart) / 1000; - const fps = Math.round(this.frameCount / deltaTime); - this.fps = fps; - this.frameCount = 0; - this.frameStart = currentTime; + // in case the current cartridge is battery backed + // then we need to check if a RAM flush to local + // storage operation is required + if (this.cartridge && this.cartridge.has_battery()) { + this.flushCycles -= counterCycles - this.pending; + if (this.flushCycles <= 0) { + this.saveRam(); + this.flushCycles = this.logicFrequency * STORE_RATE; + } } - // calculates the number of ticks that have elapsed since the - // last draw operation, this is critical to be able to properly - // operate the clock of the CPU in frame drop situations, meaning - // a situation where the system resources are no able to emulate - // the system on time and frames must be skipped (ticks > 1) - if (this.nextTickTime === 0) this.nextTickTime = currentTime; - let ticks = Math.ceil( - (currentTime - this.nextTickTime) / - ((1 / this.visualFrequency) * 1000) - ); - ticks = Math.max(ticks, 1); - - // updates the next update time according to the number of ticks - // that have elapsed since the last operation, this way this value - // can better be used to control the game loop - this.nextTickTime += (1000 / this.visualFrequency) * ticks; - // calculates the new number of pending (overflow) cycles // that are going to be added to the next iteration - return counterCycles - cycles; + this.pending = counterCycles - cycles; + } + + async hardReset() { + await wasm(true); + await this.boot({ restore: false }); } /** @@ -655,7 +527,11 @@ export class GameboyEmulator extends EmulatorBase implements Emulator { * @returns The current pixel data for the emulator display. */ get imageBuffer(): Uint8Array { - return this.gameBoy?.frame_buffer_eager() ?? new Uint8Array(); + return ( + this.clockFrame?.frame_buffer_eager() ?? + this.gameBoy?.frame_buffer_eager() ?? + new Uint8Array() + ); } get audioSpecs(): AudioSpecs { @@ -729,10 +605,6 @@ export class GameboyEmulator extends EmulatorBase implements Emulator { return this.gameBoy.wasm_engine_wa() ?? null; } - get framerate(): number { - return this.fps; - } - get registers(): Record<string, string | number> { const registers = this.gameBoy?.registers(); if (!registers) return {}; @@ -805,7 +677,7 @@ export class GameboyEmulator extends EmulatorBase implements Emulator { async resume() { this.paused = false; - this.nextTickTime = EmulatorBase.now(); + this.nextTickTime = EmulatorLogic.now(); } async reset() { @@ -899,11 +771,11 @@ export class GameboyEmulator extends EmulatorBase implements Emulator { let cycles = 0; this.pause(); try { - const initial = EmulatorBase.now(); + const initial = EmulatorLogic.now(); for (let i = 0; i < count; i++) { cycles += this.gameBoy?.clock() ?? 0; } - const delta = (EmulatorBase.now() - initial) / 1000; + const delta = (EmulatorLogic.now() - initial) / 1000; const frequency_mhz = cycles / delta / 1000 / 1000; return { delta: delta, @@ -1092,6 +964,8 @@ console.image = (url: string, size = 80) => { }; const wasm = async (setHook = true) => { + // waits for the WASM module to be (hard) re-loaded + // this should be an expensive operation await _wasm(); // in case the set hook flag is set, then tries to diff --git a/src/gb.rs b/src/gb.rs index a6bc15ec9edb0bb8e3b0249a9d132cd514a031b4..c88000aa470d0ff1400e291e19fca9c8a08b30f3 100644 --- a/src/gb.rs +++ b/src/gb.rs @@ -308,6 +308,20 @@ pub trait AudioProvider { fn clear_audio_buffer(&mut self); } +#[cfg_attr(feature = "wasm", wasm_bindgen)] +pub struct ClockFrame { + pub cycles: u64, + pub frames: u16, + frame_buffer: Option<Vec<u8>>, +} + +#[cfg_attr(feature = "wasm", wasm_bindgen)] +impl ClockFrame { + pub fn frame_buffer_eager(&mut self) -> Option<Vec<u8>> { + self.frame_buffer.take() + } +} + /// Top level structure that abstracts the usage of the /// Game Boy system under the Boytacean emulator. /// Should serve as the main entry-point API. @@ -491,6 +505,54 @@ impl GameBoy { cycles } + /// Clocks the emulator until the limit of cycles that has been + /// provided and returns the amount of cycles that have been + /// clocked. + pub fn clocks_cycles(&mut self, limit: usize) -> u64 { + let mut cycles = 0_u64; + loop { + cycles += self.clock() as u64; + if cycles >= limit as u64 { + break; + } + } + cycles + } + + /// Clocks the emulator until the limit of cycles that has been + /// provided and returns the amount of cycles that have been + /// clocked together with the frame buffer of the PPU. + /// + /// Allows a caller to clock the emulator and at the same time + /// retrieve the frame buffer of the PPU at the proper timing + /// (on V-Blank). + /// + /// This method allows for complex foreign call optimizations + /// by preventing the need to call the emulator clock multiple + /// times to obtain the right frame buffer retrieval timing. + pub fn clocks_frame_buffer(&mut self, limit: usize) -> ClockFrame { + let mut cycles = 0_u64; + let mut frames = 0_u16; + let mut frame_buffer: Option<Vec<u8>> = None; + let mut last_frame = self.ppu_frame(); + loop { + cycles += self.clock() as u64; + if self.ppu_frame() != last_frame { + frame_buffer = Some(self.frame_buffer().to_vec()); + last_frame = self.ppu_frame(); + frames += 1; + } + if cycles >= limit as u64 { + break; + } + } + ClockFrame { + cycles, + frames, + frame_buffer, + } + } + pub fn next_frame(&mut self) -> u32 { let mut cycles = 0u32; let current_frame = self.ppu_frame();