/*
 * Copyright (C) 2017 Ben Smith
 *
 * This software may be modified and distributed under the terms
 * of the MIT license.  See the LICENSE file for details.
 */
"use strict";

// User configurable.
const ROM_FILENAME = "rom/game.gb";
const ENABLE_REWIND = true;
const ENABLE_PAUSE = false;
const ENABLE_SWITCH_PALETTES = true;
const OSGP_DEADZONE = 0.1; // On screen gamepad deadzone range
const CGB_COLOR_CURVE = 2; // 0: none, 1: Sameboy "Emulate Hardware" 2: Gambatte/Gameboy Online

// List of DMG palettes to switch between. By default it includes all 84
// built-in palettes. If you want to restrict this, change it to an array of
// the palettes you want to use and change DEFAULT_PALETTE_IDX to the index of the
// default palette in that list.
//
// Example: (only allow one palette with index 16):
//   const DEFAULT_PALETTE_IDX = 0;
//   const PALETTES = [16];
//
// Example: (allow three palettes, 16, 32, 64, with default 32):
//   const DEFAULT_PALETTE_IDX = 1;
//   const PALETTES = [16, 32, 64];
//
const DEFAULT_PALETTE_IDX = 83;
const PALETTES = [
  0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21,
  22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
  41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59,
  60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78,
  79, 80, 81, 82, 83,
];

const RESULT_OK = 0;
const RESULT_ERROR = 1;
const SCREEN_WIDTH = 160;
const SCREEN_HEIGHT = 144;
const SGB_SCREEN_WIDTH = 256;
const SGB_SCREEN_HEIGHT = 224;
const SGB_SCREEN_LEFT = (SGB_SCREEN_WIDTH - SCREEN_WIDTH) >> 1;
const SGB_SCREEN_RIGHT = (SGB_SCREEN_WIDTH + SCREEN_WIDTH) >> 1;
const SGB_SCREEN_TOP = (SGB_SCREEN_HEIGHT - SCREEN_HEIGHT) >> 1;
const SGB_SCREEN_BOTTOM = (SGB_SCREEN_HEIGHT + SCREEN_HEIGHT) >> 1;
const AUDIO_FRAMES = 4096;
const AUDIO_LATENCY_SEC = 0.1;
const MAX_UPDATE_SEC = 5 / 60;
const CPU_TICKS_PER_SECOND = 4194304;
const EVENT_NEW_FRAME = 1;
const EVENT_AUDIO_BUFFER_FULL = 2;
const EVENT_UNTIL_TICKS = 4;
const REWIND_FRAMES_PER_BASE_STATE = 45;
const REWIND_BUFFER_CAPACITY = 4 * 1024 * 1024;
const REWIND_FACTOR = 1.5;
const REWIND_UPDATE_MS = 16;
const GAMEPAD_POLLING_INTERVAL = 1000 / 60 / 4; // When activated, poll for gamepad input about ~4 times per gameboy frame (~240 times second)
const GAMEPAD_KEYMAP_STANDARD_STR = "standard"; // Try to use "standard" HTML5 mapping config if available

const $ = document.querySelector.bind(document);
let emulator = null;

const controllerEl = $("#controller");
const dpadEl = $("#controller_dpad");
const selectEl = $("#controller_select");
const startEl = $("#controller_start");
const bEl = $("#controller_b");
const aEl = $("#controller_a");

const binjgbPromise = Binjgb();

const sgbEnabled = window.location.href.includes("sgb=true");
if (sgbEnabled) {
  $("canvas").width = SGB_SCREEN_WIDTH;
  $("canvas").height = SGB_SCREEN_HEIGHT;
} else {
  $("canvas").width = SCREEN_WIDTH;
  $("canvas").height = SCREEN_HEIGHT;
}

// Extract stuff from the vue.js implementation in demo.js.
class VM {
  constructor() {
    this.ticks = 0;
    this.extRamUpdated = false;
    this.paused_ = false;
    this.volume = 0.5;
    this.palIdx = DEFAULT_PALETTE_IDX;
    this.canvas = {
      show: true,
      useSgbBorder: sgbEnabled,
      scale: 3,
    };
    this.rewind = {
      minTicks: 0,
      maxTicks: 0,
    };
    setInterval(() => {
      if (this.extRamUpdated) {
        this.updateExtRam();
        this.extRamUpdated = false;
      }
    }, 1000);
  }

  get paused() {
    return this.paused_;
  }
  set paused(newPaused) {
    let oldPaused = this.paused_;
    this.paused_ = newPaused;
    if (!emulator) return;
    if (newPaused === oldPaused) return;
    if (newPaused) {
      emulator.pause();
      this.ticks = emulator.ticks;
      this.rewind.minTicks = emulator.rewind.oldestTicks;
      this.rewind.maxTicks = emulator.rewind.newestTicks;
    } else {
      emulator.resume();
    }
  }

  togglePause() {
    this.paused = !this.paused;
  }

  updateExtRam() {
    if (!emulator) return;
    const extram = emulator.getExtRam();
    localStorage.setItem("extram", JSON.stringify(Array.from(extram)));
  }
}

const vm = new VM();

// Load a ROM.
(async function go() {
  let response = await fetch(ROM_FILENAME);
  let romBuffer = await response.arrayBuffer();
  const extRam = new Uint8Array(JSON.parse(localStorage.getItem("extram")));
  Emulator.start(await binjgbPromise, romBuffer, extRam);
  emulator.setBuiltinPalette(vm.palIdx);
})();

function makeWasmBuffer(module, ptr, size) {
  return new Uint8Array(module.HEAP8.buffer, ptr, size);
}

class Emulator {
  static start(module, romBuffer, extRamBuffer) {
    Emulator.stop();
    emulator = new Emulator(module, romBuffer, extRamBuffer);
    emulator.run();
  }

  static stop() {
    if (emulator) {
      emulator.destroy();
      emulator = null;
    }
  }

  constructor(module, romBuffer, extRamBuffer) {
    this.module = module;
    this.romDataPtr = this.module._malloc(romBuffer.byteLength);
    makeWasmBuffer(this.module, this.romDataPtr, romBuffer.byteLength).set(
      new Uint8Array(romBuffer)
    );
    this.e = this.module._emulator_new_simple(
      this.romDataPtr,
      romBuffer.byteLength,
      Audio.ctx.sampleRate,
      AUDIO_FRAMES,
      CGB_COLOR_CURVE
    );
    if (this.e == 0) {
      throw new Error("Invalid ROM.");
    }

    this.gamepad = new Gamepad(module, this.e);
    this.audio = new Audio(module, this.e);
    this.video = new Video(module, this.e, $("canvas"));
    this.rewind = new Rewind(module, this.e);
    this.rewindIntervalId = 0;

    this.lastRafSec = 0;
    this.leftoverTicks = 0;
    this.fps = 60;

    if (extRamBuffer) {
      this.loadExtRam(extRamBuffer);
    }

    this.bindKeys();
    this.bindTouch();

    this.touchEnabled = "ontouchstart" in document.documentElement;
    this.updateOnscreenGamepad();

    this.gamepad.init();
  }

  destroy() {
    this.gamepad.shutdown();
    this.unbindTouch();
    this.unbindKeys();
    this.cancelAnimationFrame();
    clearInterval(this.rewindIntervalId);
    this.rewind.destroy();
    this.module._emulator_delete(this.e);
    this.module._free(this.romDataPtr);
  }

  withNewFileData(cb) {
    const fileDataPtr = this.module._ext_ram_file_data_new(this.e);
    const buffer = makeWasmBuffer(
      this.module,
      this.module._get_file_data_ptr(fileDataPtr),
      this.module._get_file_data_size(fileDataPtr)
    );
    const result = cb(fileDataPtr, buffer);
    this.module._file_data_delete(fileDataPtr);
    return result;
  }

  loadExtRam(extRamBuffer) {
    this.withNewFileData((fileDataPtr, buffer) => {
      if (buffer.byteLength === extRamBuffer.byteLength) {
        buffer.set(new Uint8Array(extRamBuffer));
        this.module._emulator_read_ext_ram(this.e, fileDataPtr);
      }
    });
  }

  getExtRam() {
    return this.withNewFileData((fileDataPtr, buffer) => {
      this.module._emulator_write_ext_ram(this.e, fileDataPtr);
      return new Uint8Array(buffer);
    });
  }

  get isPaused() {
    return this.rafCancelToken === null;
  }

  pause() {
    if (!this.isPaused) {
      this.cancelAnimationFrame();
      this.audio.pause();
      this.beginRewind();
    }
  }

  resume() {
    if (this.isPaused) {
      this.endRewind();
      this.requestAnimationFrame();
      this.audio.resume();
    }
  }

  setBuiltinPalette(palIdx) {
    this.module._emulator_set_builtin_palette(this.e, PALETTES[palIdx]);
  }

  get isRewinding() {
    return this.rewind.isRewinding;
  }

  beginRewind() {
    this.rewind.beginRewind();
  }

  rewindToTicks(ticks) {
    if (this.rewind.rewindToTicks(ticks)) {
      this.runUntil(ticks);
      this.video.renderTexture();
    }
  }

  endRewind() {
    this.rewind.endRewind();
    this.lastRafSec = 0;
    this.leftoverTicks = 0;
    this.audio.startSec = 0;
  }

  set autoRewind(enabled) {
    if (enabled) {
      this.rewindIntervalId = setInterval(() => {
        const oldest = this.rewind.oldestTicks;
        const start = this.ticks;
        const delta =
          ((REWIND_FACTOR * REWIND_UPDATE_MS) / 1000) * CPU_TICKS_PER_SECOND;
        const rewindTo = Math.max(oldest, start - delta);
        this.rewindToTicks(rewindTo);
        vm.ticks = emulator.ticks;
      }, REWIND_UPDATE_MS);
    } else {
      clearInterval(this.rewindIntervalId);
      this.rewindIntervalId = 0;
    }
  }

  requestAnimationFrame() {
    this.rafCancelToken = requestAnimationFrame(this.rafCallback.bind(this));
  }

  cancelAnimationFrame() {
    cancelAnimationFrame(this.rafCancelToken);
    this.rafCancelToken = null;
  }

  run() {
    this.requestAnimationFrame();
  }

  get ticks() {
    return this.module._emulator_get_ticks_f64(this.e);
  }

  runUntil(ticks) {
    while (true) {
      const event = this.module._emulator_run_until_f64(this.e, ticks);
      if (event & EVENT_NEW_FRAME) {
        this.rewind.pushBuffer();
        this.video.uploadTexture();
      }
      if (event & EVENT_AUDIO_BUFFER_FULL && !this.isRewinding) {
        this.audio.pushBuffer();
      }
      if (event & EVENT_UNTIL_TICKS) {
        break;
      }
    }
    if (this.module._emulator_was_ext_ram_updated(this.e)) {
      vm.extRamUpdated = true;
    }
  }

  rafCallback(startMs) {
    this.requestAnimationFrame();
    let deltaSec = 0;
    if (!this.isRewinding) {
      const startSec = startMs / 1000;
      deltaSec = Math.max(startSec - (this.lastRafSec || startSec), 0);
      const startTicks = this.ticks;
      const deltaTicks =
        Math.min(deltaSec, MAX_UPDATE_SEC) * CPU_TICKS_PER_SECOND;
      const runUntilTicks = startTicks + deltaTicks - this.leftoverTicks;
      this.runUntil(runUntilTicks);
      this.leftoverTicks = (this.ticks - runUntilTicks) | 0;
      this.lastRafSec = startSec;
    }
    const lerp = (from, to, alpha) => alpha * from + (1 - alpha) * to;
    this.fps = lerp(this.fps, Math.min(1 / deltaSec, 10000), 0.3);
    this.video.renderTexture();
  }

  updateOnscreenGamepad() {
    $("#controller").style.display = this.touchEnabled ? "block" : "none";
  }

  bindTouch() {
    this.touchFuncs = {
      controller_b: this.setJoypB.bind(this),
      controller_a: this.setJoypA.bind(this),
      controller_start: this.setJoypStart.bind(this),
      controller_select: this.setJoypSelect.bind(this),
    };

    this.boundButtonTouchStart = this.buttonTouchStart.bind(this);
    this.boundButtonTouchEnd = this.buttonTouchEnd.bind(this);
    selectEl.addEventListener("touchstart", this.boundButtonTouchStart);
    selectEl.addEventListener("touchend", this.boundButtonTouchEnd);
    startEl.addEventListener("touchstart", this.boundButtonTouchStart);
    startEl.addEventListener("touchend", this.boundButtonTouchEnd);
    bEl.addEventListener("touchstart", this.boundButtonTouchStart);
    bEl.addEventListener("touchend", this.boundButtonTouchEnd);
    aEl.addEventListener("touchstart", this.boundButtonTouchStart);
    aEl.addEventListener("touchend", this.boundButtonTouchEnd);

    this.boundDpadTouchStartMove = this.dpadTouchStartMove.bind(this);
    this.boundDpadTouchEnd = this.dpadTouchEnd.bind(this);
    dpadEl.addEventListener("touchstart", this.boundDpadTouchStartMove);
    dpadEl.addEventListener("touchmove", this.boundDpadTouchStartMove);
    dpadEl.addEventListener("touchend", this.boundDpadTouchEnd);

    this.boundTouchRestore = this.touchRestore.bind(this);
    window.addEventListener("touchstart", this.boundTouchRestore);
  }

  unbindTouch() {
    selectEl.removeEventListener("touchstart", this.boundButtonTouchStart);
    selectEl.removeEventListener("touchend", this.boundButtonTouchEnd);
    startEl.removeEventListener("touchstart", this.boundButtonTouchStart);
    startEl.removeEventListener("touchend", this.boundButtonTouchEnd);
    bEl.removeEventListener("touchstart", this.boundButtonTouchStart);
    bEl.removeEventListener("touchend", this.boundButtonTouchEnd);
    aEl.removeEventListener("touchstart", this.boundButtonTouchStart);
    aEl.removeEventListener("touchend", this.boundButtonTouchEnd);

    dpadEl.removeEventListener("touchstart", this.boundDpadTouchStartMove);
    dpadEl.removeEventListener("touchmove", this.boundDpadTouchStartMove);
    dpadEl.removeEventListener("touchend", this.boundDpadTouchEnd);

    window.removeEventListener("touchstart", this.boundTouchRestore);
  }

  buttonTouchStart(event) {
    if (event.currentTarget.id in this.touchFuncs) {
      this.touchFuncs[event.currentTarget.id](true);
      event.currentTarget.classList.add("btnPressed");
      event.preventDefault();
    }
  }

  buttonTouchEnd(event) {
    if (event.currentTarget.id in this.touchFuncs) {
      this.touchFuncs[event.currentTarget.id](false);
      event.currentTarget.classList.remove("btnPressed");
      event.preventDefault();
    }
  }

  dpadTouchStartMove(event) {
    const rect = event.currentTarget.getBoundingClientRect();
    const x =
      (2 * (event.targetTouches[0].clientX - rect.left)) / rect.width - 1;
    const y =
      (2 * (event.targetTouches[0].clientY - rect.top)) / rect.height - 1;

    if (Math.abs(x) > OSGP_DEADZONE) {
      if (y > x && y < -x) {
        this.setJoypLeft(true);
        this.setJoypRight(false);
      } else if (y < x && y > -x) {
        this.setJoypLeft(false);
        this.setJoypRight(true);
      }
    } else {
      this.setJoypLeft(false);
      this.setJoypRight(false);
    }

    if (Math.abs(y) > OSGP_DEADZONE) {
      if (x > y && x < -y) {
        this.setJoypUp(true);
        this.setJoypDown(false);
      } else if (x < y && x > -y) {
        this.setJoypUp(false);
        this.setJoypDown(true);
      }
    } else {
      this.setJoypUp(false);
      this.setJoypDown(false);
    }
    event.preventDefault();
  }

  dpadTouchEnd(event) {
    this.setJoypLeft(false);
    this.setJoypRight(false);
    this.setJoypUp(false);
    this.setJoypDown(false);
    event.preventDefault();
  }

  touchRestore() {
    this.touchEnabled = true;
    this.updateOnscreenGamepad();
  }

  bindKeys() {
    this.keyFuncs = {
      Backspace: this.keyRewind.bind(this),
      " ": this.keyPause.bind(this),
      "[": this.keyPrevPalette.bind(this),
      "]": this.keyNextPalette.bind(this),
    };

    if (customControls.down && customControls.down.length > 0) {
      customControls.down.forEach((k) => {
        this.keyFuncs[k] = this.setJoypDown.bind(this);
      });
    } else {
      this.keyFuncs["ArrowDown"] = this.setJoypDown.bind(this);
      this.keyFuncs["s"] = this.setJoypDown.bind(this);
    }

    if (customControls.left && customControls.left.length > 0) {
      customControls.left.forEach((k) => {
        this.keyFuncs[k] = this.setJoypLeft.bind(this);
      });
    } else {
      this.keyFuncs["ArrowLeft"] = this.setJoypLeft.bind(this);
      this.keyFuncs["a"] = this.setJoypLeft.bind(this);
    }

    if (customControls.right && customControls.right.length > 0) {
      customControls.right.forEach((k) => {
        this.keyFuncs[k] = this.setJoypRight.bind(this);
      });
    } else {
      this.keyFuncs["ArrowRight"] = this.setJoypRight.bind(this);
      this.keyFuncs["d"] = this.setJoypRight.bind(this);
    }

    if (customControls.up && customControls.up.length > 0) {
      customControls.up.forEach((k) => {
        this.keyFuncs[k] = this.setJoypUp.bind(this);
      });
    } else {
      this.keyFuncs["ArrowUp"] = this.setJoypUp.bind(this);
      this.keyFuncs["w"] = this.setJoypUp.bind(this);
    }

    if (customControls.a && customControls.a.length > 0) {
      customControls.a.forEach((k) => {
        this.keyFuncs[k] = this.setJoypA.bind(this);
      });
    } else {
      this.keyFuncs["z"] = this.setJoypA.bind(this);
      this.keyFuncs["j"] = this.setJoypA.bind(this);
      this.keyFuncs["Alt"] = this.setJoypA.bind(this);
    }

    if (customControls.b && customControls.b.length > 0) {
      customControls.b.forEach((k) => {
        this.keyFuncs[k] = this.setJoypB.bind(this);
      });
    } else {
      this.keyFuncs["x"] = this.setJoypB.bind(this);
      this.keyFuncs["k"] = this.setJoypB.bind(this);
      this.keyFuncs["Control"] = this.setJoypB.bind(this);
    }

    if (customControls.start && customControls.start.length > 0) {
      customControls.start.forEach((k) => {
        this.keyFuncs[k] = this.setJoypStart.bind(this);
      });
    } else {
      this.keyFuncs["Enter"] = this.setJoypStart.bind(this);
    }

    if (customControls.select && customControls.select.length > 0) {
      customControls.select.forEach((k) => {
        this.keyFuncs[k] = this.setJoypSelect.bind(this);
      });
    } else {
      this.keyFuncs["Shift"] = this.setJoypSelect.bind(this);
    }

    this.boundKeyDown = this.keyDown.bind(this);
    this.boundKeyUp = this.keyUp.bind(this);

    window.addEventListener("keydown", this.boundKeyDown);
    window.addEventListener("keyup", this.boundKeyUp);
  }

  unbindKeys() {
    window.removeEventListener("keydown", this.boundKeyDown);
    window.removeEventListener("keyup", this.boundKeyUp);
  }

  keyDown(event) {
    if (event.key === "w" && (event.metaKey || event.ctrlKey)) {
      return;
    }
    if (event.key in this.keyFuncs) {
      if (this.touchEnabled) {
        this.touchEnabled = false;
        this.updateOnscreenGamepad();
      }
      this.keyFuncs[event.key](true);
      event.preventDefault();
    }
  }

  keyUp(event) {
    if (event.key in this.keyFuncs) {
      this.keyFuncs[event.key](false);
      event.preventDefault();
    }
  }

  keyRewind(isKeyDown) {
    if (!ENABLE_REWIND) {
      return;
    }
    if (this.isRewinding !== isKeyDown) {
      if (isKeyDown) {
        vm.paused = true;
        this.autoRewind = true;
      } else {
        this.autoRewind = false;
        vm.paused = false;
      }
    }
  }

  keyPause(isKeyDown) {
    if (!ENABLE_PAUSE) {
      return;
    }
    if (isKeyDown) vm.togglePause();
  }

  keyPrevPalette(isKeyDown) {
    if (!ENABLE_SWITCH_PALETTES) {
      return;
    }
    if (isKeyDown) {
      vm.palIdx = (vm.palIdx + PALETTES.length - 1) % PALETTES.length;
      emulator.setBuiltinPalette(vm.palIdx);
    }
  }

  keyNextPalette(isKeyDown) {
    if (!ENABLE_SWITCH_PALETTES) {
      return;
    }
    if (isKeyDown) {
      vm.palIdx = (vm.palIdx + 1) % PALETTES.length;
      emulator.setBuiltinPalette(vm.palIdx);
    }
  }

  setJoypDown(set) {
    this.module._set_joyp_down(this.e, set);
  }
  setJoypUp(set) {
    this.module._set_joyp_up(this.e, set);
  }
  setJoypLeft(set) {
    this.module._set_joyp_left(this.e, set);
  }
  setJoypRight(set) {
    this.module._set_joyp_right(this.e, set);
  }
  setJoypSelect(set) {
    this.module._set_joyp_select(this.e, set);
  }
  setJoypStart(set) {
    this.module._set_joyp_start(this.e, set);
  }
  setJoypB(set) {
    this.module._set_joyp_B(this.e, set);
  }
  setJoypA(set) {
    this.module._set_joyp_A(this.e, set);
  }
}

class Gamepad {
  constructor(module, e) {
    this.module = module;
    this.e = e;
  }

  // Load a key map for gamepad-to-gameboy buttons
  bindKeys(strMapping) {
    this.GAMEPAD_KEYMAP_STANDARD = [
      {
        gb_key: "b",
        gp_button: 0,
        type: "button",
        gp_bind: this.module._set_joyp_B.bind(null, this.e),
      },
      {
        gb_key: "a",
        gp_button: 1,
        type: "button",
        gp_bind: this.module._set_joyp_A.bind(null, this.e),
      },
      {
        gb_key: "select",
        gp_button: 8,
        type: "button",
        gp_bind: this.module._set_joyp_select.bind(null, this.e),
      },
      {
        gb_key: "start",
        gp_button: 9,
        type: "button",
        gp_bind: this.module._set_joyp_start.bind(null, this.e),
      },
      {
        gb_key: "up",
        gp_button: 12,
        type: "button",
        gp_bind: this.module._set_joyp_up.bind(null, this.e),
      },
      {
        gb_key: "down",
        gp_button: 13,
        type: "button",
        gp_bind: this.module._set_joyp_down.bind(null, this.e),
      },
      {
        gb_key: "left",
        gp_button: 14,
        type: "button",
        gp_bind: this.module._set_joyp_left.bind(null, this.e),
      },
      {
        gb_key: "right",
        gp_button: 15,
        type: "button",
        gp_bind: this.module._set_joyp_right.bind(null, this.e),
      },
    ];

    this.GAMEPAD_KEYMAP_DEFAULT = [
      {
        gb_key: "a",
        gp_button: 0,
        type: "button",
        gp_bind: this.module._set_joyp_A.bind(null, this.e),
      },
      {
        gb_key: "b",
        gp_button: 1,
        type: "button",
        gp_bind: this.module._set_joyp_B.bind(null, this.e),
      },
      {
        gb_key: "select",
        gp_button: 2,
        type: "button",
        gp_bind: this.module._set_joyp_select.bind(null, this.e),
      },
      {
        gb_key: "start",
        gp_button: 3,
        type: "button",
        gp_bind: this.module._set_joyp_start.bind(null, this.e),
      },
      {
        gb_key: "up",
        gp_button: 2,
        type: "axis",
        gp_bind: this.module._set_joyp_up.bind(null, this.e),
      },
      {
        gb_key: "down",
        gp_button: 3,
        type: "axis",
        gp_bind: this.module._set_joyp_down.bind(null, this.e),
      },
      {
        gb_key: "left",
        gp_button: 0,
        type: "axis",
        gp_bind: this.module._set_joyp_left.bind(null, this.e),
      },
      {
        gb_key: "right",
        gp_button: 1,
        type: "axis",
        gp_bind: this.module._set_joyp_right.bind(null, this.e),
      },
    ];

    // Try to use the w3c "standard" gamepad mapping if available
    // (Chrome/V8 seems to do that better than Firefox)
    //
    // Otherwise use a default mapping that assigns
    // A/B/Select/Start to the first four buttons,
    // and U/D/L/R to the first two axes.
    if (strMapping === GAMEPAD_KEYMAP_STANDARD_STR) {
      this.gp.keybinds = this.GAMEPAD_KEYMAP_STANDARD;
    } else {
      this.gp.keybinds = this.GAMEPAD_KEYMAP_DEFAULT;
    }
  }

  cacheValues(gamepad) {
    // Read Buttons
    for (let k = 0; k < gamepad.buttons.length; k++) {
      // .value is for analog, .pressed is for boolean buttons
      this.gp.buttons.cur[k] =
        gamepad.buttons[k].value > 0 || gamepad.buttons[k].pressed == true;

      // Update state changed if not on first input pass
      if (this.gp.buttons.last !== undefined) {
        this.gp.buttons.changed[k] =
          this.gp.buttons.cur[k] != this.gp.buttons.last[k];
      }
    }

    // Read Axes
    for (let k = 0; k < gamepad.axes.length; k++) {
      // Decode each dpad axis into two buttons, one for each direction
      this.gp.axes.cur[k * 2] = gamepad.axes[k] < 0;
      this.gp.axes.cur[k * 2 + 1] = gamepad.axes[k] > 0;

      // Update state changed if not on first input pass
      if (this.gp.axes.last !== undefined) {
        this.gp.axes.changed[k * 2] =
          this.gp.axes.cur[k * 2] != this.gp.axes.last[k * 2];
        this.gp.axes.changed[k * 2 + 1] =
          this.gp.axes.cur[k * 2 + 1] != this.gp.axes.last[k * 2 + 1];
      }
    }

    // Save current state for comparison on next input
    this.gp.axes.last = this.gp.axes.cur.slice(0);
    this.gp.buttons.last = this.gp.buttons.cur.slice(0);
  }

  handleButton(keyBind) {
    let buttonCache;

    // Select button / axis cache based on key bind type
    if (keyBind.type === "button") {
      buttonCache = this.gp.buttons;
    } else if (keyBind.type === "axis") {
      buttonCache = this.gp.axes;
    }

    // Make sure the button exists in the cache array
    if (keyBind.gp_button < buttonCache.changed.length) {
      // Send the button state if it's changed
      if (buttonCache.changed[keyBind.gp_button]) {
        if (buttonCache.cur[keyBind.gp_button]) {
          // Gamepad Button Down
          keyBind.gp_bind(true);
        } else {
          // Gamepad Button Up
          keyBind.gp_bind(false);
        }
      }
    }
  }

  getCurrent() {
    // Chrome requires retrieving a new gamepad object
    // every time button state is queried (the existing object
    // will have stale button state). Just do that for all browsers
    let gamepad = navigator.getGamepads()[this.gp.apiID];

    if (gamepad) {
      if (gamepad.connected) {
        return gamepad;
      }
    }

    return undefined;
  }

  update() {
    let gamepad = this.getCurrent();

    if (gamepad !== undefined) {
      // Cache gamepad input values
      this.cacheValues(gamepad);

      // Loop through buttons and send changes if needed
      for (let i = 0; i < this.gp.keybinds.length; i++) {
        this.handleButton(this.gp.keybinds[i]);
      }
    } else {
      // Gamepad is no longer present, disconnect
      this.releaseGamepad();
    }
  }

  startGamepad(gamepad) {
    // Make sure it has enough buttons and axes
    if (
      gamepad.mapping === GAMEPAD_KEYMAP_STANDARD_STR ||
      (gamepad.axes.length >= 2 && gamepad.buttons.length >= 4)
    ) {
      // Save API index for polling (required by Chrome/V8)
      this.gp.apiID = gamepad.index;

      // Assign gameboy keys to the gamepad
      this.bindKeys(gamepad.mapping);

      // Start polling the gamepad for input
      this.gp.timerID = setInterval(
        () => this.update(),
        GAMEPAD_POLLING_INTERVAL
      );
    }
  }

  releaseGamepad() {
    // Stop polling the gamepad for input
    if (this.gp.timerID !== undefined) {
      clearInterval(this.gp.timerID);
    }

    // Clear previous button history and controller info
    this.gp.axes.last = undefined;
    this.gp.buttons.last = undefined;
    this.gp.keybinds = undefined;

    this.gp.apiID = undefined;
  }

  // If a gamepad was already connected on this page
  // and released, it won't fire another connect event.
  // So try to find any that might be present
  checkAlreadyConnected() {
    let gamepads = navigator.getGamepads();

    // If any gamepads are already attached to the page,
    // use the first one that is connected
    for (let idx = 0; idx < gamepads.length; idx++) {
      if (gamepads[idx] !== undefined && gamepads[idx] !== null) {
        if (gamepads[idx].connected === true) {
          this.startGamepad(gamepads[idx]);
        }
      }
    }
  }

  // Event handler for when a gamepad is connected
  eventConnected(event) {
    this.startGamepad(navigator.getGamepads()[event.gamepad.index]);
  }

  // Event handler for when a gamepad is disconnected
  eventDisconnected(event) {
    this.releaseGamepad();
  }

  // Register event connection handlers for gamepads
  init() {
    // gamepad related vars
    this.gp = {
      apiID: undefined,
      timerID: undefined,
      keybinds: undefined,
      axes: { last: undefined, cur: [], changed: [] },
      buttons: { last: undefined, cur: [], changed: [] },
    };

    // Check for previously attached gamepads that might
    // not emit a gamepadconnected() event
    this.checkAlreadyConnected();

    this.boundGamepadConnected = this.eventConnected.bind(this);
    this.boundGamepadDisconnected = this.eventDisconnected.bind(this);

    // When a gamepad connects, start polling it for input
    window.addEventListener("gamepadconnected", this.boundGamepadConnected);

    // When a gamepad disconnects, shut down polling for input
    window.addEventListener(
      "gamepaddisconnected",
      this.boundGamepadDisconnected
    );
  }

  // Release event connection handlers and settings
  shutdown() {
    this.releaseGamepad();
    window.removeEventListener("gamepadconnected", this.boundGamepadConnected);
    window.removeEventListener(
      "gamepaddisconnected",
      this.boundGamepadDisconnected
    );
  }
}

class Audio {
  constructor(module, e) {
    this.started = false;
    this.module = module;
    this.buffer = makeWasmBuffer(
      this.module,
      this.module._get_audio_buffer_ptr(e),
      this.module._get_audio_buffer_capacity(e)
    );
    this.startSec = 0;
    this.resume();

    this.boundStartPlayback = this.startPlayback.bind(this);
    window.addEventListener("keydown", this.boundStartPlayback, true);
    window.addEventListener("click", this.boundStartPlayback, true);
    window.addEventListener("touchend", this.boundStartPlayback, true);
  }

  startPlayback() {
    window.removeEventListener("touchend", this.boundStartPlayback, true);
    window.removeEventListener("keydown", this.boundStartPlayback, true);
    window.removeEventListener("click", this.boundStartPlayback, true);
    this.started = true;
    this.resume();
  }

  get sampleRate() {
    return Audio.ctx.sampleRate;
  }

  pushBuffer() {
    if (!this.started) {
      return;
    }
    const nowSec = Audio.ctx.currentTime;
    const nowPlusLatency = nowSec + AUDIO_LATENCY_SEC;
    const volume = vm.volume;
    this.startSec = this.startSec || nowPlusLatency;
    if (this.startSec >= nowSec) {
      const buffer = Audio.ctx.createBuffer(2, AUDIO_FRAMES, this.sampleRate);
      const channel0 = buffer.getChannelData(0);
      const channel1 = buffer.getChannelData(1);
      for (let i = 0; i < AUDIO_FRAMES; i++) {
        channel0[i] = (this.buffer[2 * i] * volume) / 255;
        channel1[i] = (this.buffer[2 * i + 1] * volume) / 255;
      }
      const bufferSource = Audio.ctx.createBufferSource();
      bufferSource.buffer = buffer;
      bufferSource.connect(Audio.ctx.destination);
      bufferSource.start(this.startSec);
      const bufferSec = AUDIO_FRAMES / this.sampleRate;
      this.startSec += bufferSec;
    } else {
      console.log(
        "Resetting audio (" +
          this.startSec.toFixed(2) +
          " < " +
          nowSec.toFixed(2) +
          ")"
      );
      this.startSec = nowPlusLatency;
    }
  }

  pause() {
    if (!this.started) {
      return;
    }
    Audio.ctx.suspend();
  }

  resume() {
    if (!this.started) {
      return;
    }
    Audio.ctx.resume();
  }
}

Audio.ctx = new AudioContext();

class Video {
  constructor(module, e, el) {
    this.module = module;
    // Both iPhone and Desktop Safari dont't upscale using image-rendering: pixelated
    // on webgl canvases. See https://bugs.webkit.org/show_bug.cgi?id=193895.
    // For now, default to Canvas2D.
    if (window.navigator.userAgent.match(/iPhone|iPad|15.[0-9] Safari/)) {
      this.renderer = new Canvas2DRenderer(el);
    } else {
      try {
        this.renderer = new WebGLRenderer(el);
      } catch (error) {
        console.log(`Error creating WebGLRenderer: ${error}`);
        this.renderer = new Canvas2DRenderer(el);
      }
    }
    this.buffer = makeWasmBuffer(
      this.module,
      this.module._get_frame_buffer_ptr(e),
      this.module._get_frame_buffer_size(e)
    );
    this.sgbBuffer = makeWasmBuffer(
      this.module,
      this.module._get_sgb_frame_buffer_ptr(e),
      this.module._get_sgb_frame_buffer_size(e)
    );
  }

  uploadTexture() {
    this.renderer.uploadTextures(this.buffer, this.sgbBuffer);
  }

  renderTexture() {
    this.renderer.renderTextures();
  }
}

class Canvas2DRenderer {
  constructor(el) {
    this.ctx = el.getContext("2d");
    this.imageData = this.ctx.createImageData(SCREEN_WIDTH, SCREEN_HEIGHT);
    this.sgbImageData = this.ctx.createImageData(
      SGB_SCREEN_WIDTH,
      SGB_SCREEN_HEIGHT
    );

    this.overlayCanvas = document.createElement("canvas");
    this.overlayCanvas.width = SGB_SCREEN_WIDTH;
    this.overlayCanvas.height = SGB_SCREEN_HEIGHT;
    this.overlayCtx = this.overlayCanvas.getContext("2d");
  }

  uploadTextures(buffer, sgbBuffer) {
    this.imageData.data.set(buffer);
    this.sgbImageData.data.set(sgbBuffer);
  }

  renderTextures() {
    if (vm.canvas.useSgbBorder) {
      this.ctx.putImageData(this.imageData, SGB_SCREEN_LEFT, SGB_SCREEN_TOP);
      this.overlayCtx.putImageData(this.sgbImageData, 0, 0);
      this.ctx.drawImage(this.overlayCanvas, 0, 0);
    } else {
      this.ctx.putImageData(this.imageData, 0, 0);
    }
  }
}

class WebGLRenderer {
  constructor(el) {
    const gl = (this.gl = el.getContext("webgl", {
      preserveDrawingBuffer: true,
    }));
    if (gl === null) {
      throw new Error("unable to create webgl context");
    }

    function compileShader(type, source) {
      const shader = gl.createShader(type);
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
        throw new Error(`compileShader failed: ${gl.getShaderInfoLog(shader)}`);
      }
      return shader;
    }

    const vertexShader = compileShader(
      gl.VERTEX_SHADER,
      `attribute vec2 aPos;
        attribute vec2 aTexCoord;
        varying highp vec2 vTexCoord;
        void main(void) {
          gl_Position = vec4(aPos, 0.0, 1.0);
          vTexCoord = aTexCoord;
        }`
    );
    const fragmentShader = compileShader(
      gl.FRAGMENT_SHADER,
      `varying highp vec2 vTexCoord;
        uniform sampler2D uSampler;
        void main(void) {
          gl_FragColor = texture2D(uSampler, vTexCoord);
        }`
    );

    const program = gl.createProgram();
    gl.attachShader(program, vertexShader);
    gl.attachShader(program, fragmentShader);
    gl.linkProgram(program);
    if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
      throw new Error(`program link failed: ${gl.getProgramInfoLog(program)}`);
    }
    gl.useProgram(program);

    this.aPos = gl.getAttribLocation(program, "aPos");
    this.aTexCoord = gl.getAttribLocation(program, "aTexCoord");
    this.uSampler = gl.getUniformLocation(program, "uSampler");

    this.fbTexture = this.createTexture();
    this.sgbFbTexture = this.createTexture();

    const invLerpClipSpace = (x, max) => 2 * (x / max) - 1;
    const l = invLerpClipSpace(SGB_SCREEN_LEFT, SGB_SCREEN_WIDTH);
    const r = invLerpClipSpace(SGB_SCREEN_RIGHT, SGB_SCREEN_WIDTH);
    const t = -invLerpClipSpace(SGB_SCREEN_TOP, SGB_SCREEN_HEIGHT);
    const b = -invLerpClipSpace(SGB_SCREEN_BOTTOM, SGB_SCREEN_HEIGHT);
    const w = SCREEN_WIDTH / 256,
      sw = SGB_SCREEN_WIDTH / 256;
    const h = SCREEN_HEIGHT / 256,
      sh = SGB_SCREEN_HEIGHT / 256;

    const verts = new Float32Array([
      // fb only
      -1,
      -1,
      0,
      h,
      +1,
      -1,
      w,
      h,
      -1,
      +1,
      0,
      0,
      +1,
      +1,
      w,
      0,

      // sgb fb
      l,
      b,
      0,
      h,
      r,
      b,
      w,
      h,
      l,
      t,
      0,
      0,
      r,
      t,
      w,
      0,

      // sgb border
      -1,
      -1,
      0,
      sh,
      +1,
      -1,
      sw,
      sh,
      -1,
      +1,
      0,
      0,
      +1,
      +1,
      sw,
      0,
    ]);

    const buffer = gl.createBuffer();
    this.gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
    gl.bufferData(gl.ARRAY_BUFFER, verts, gl.STATIC_DRAW);

    gl.enableVertexAttribArray(this.aPos);
    gl.enableVertexAttribArray(this.aTexCoord);
    gl.vertexAttribPointer(this.aPos, 2, gl.FLOAT, gl.FALSE, 16, 0);
    gl.vertexAttribPointer(this.aTexCoord, 2, gl.FLOAT, gl.FALSE, 16, 8);
    gl.uniform1i(this.uSampler, 0);
  }

  createTexture() {
    const gl = this.gl;
    const texture = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, texture);
    gl.texImage2D(
      gl.TEXTURE_2D,
      0,
      gl.RGBA,
      256,
      256,
      0,
      gl.RGBA,
      gl.UNSIGNED_BYTE,
      null
    );
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
    return texture;
  }

  uploadTextures(buffer, sgbBuffer) {
    const gl = this.gl;
    gl.bindTexture(gl.TEXTURE_2D, this.fbTexture);
    gl.texSubImage2D(
      gl.TEXTURE_2D,
      0,
      0,
      0,
      SCREEN_WIDTH,
      SCREEN_HEIGHT,
      gl.RGBA,
      gl.UNSIGNED_BYTE,
      buffer
    );

    gl.bindTexture(gl.TEXTURE_2D, this.sgbFbTexture);
    gl.texSubImage2D(
      gl.TEXTURE_2D,
      0,
      0,
      0,
      SGB_SCREEN_WIDTH,
      SGB_SCREEN_HEIGHT,
      gl.RGBA,
      gl.UNSIGNED_BYTE,
      sgbBuffer
    );
  }

  renderTextures() {
    const gl = this.gl;
    gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight);
    gl.clearColor(0.5, 0.5, 0.5, 1.0);
    gl.clear(gl.COLOR_BUFFER_BIT);

    if (vm.canvas.useSgbBorder) {
      gl.bindTexture(gl.TEXTURE_2D, this.fbTexture);
      gl.drawArrays(gl.TRIANGLE_STRIP, 4, 4);

      gl.enable(gl.BLEND);
      gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
      gl.bindTexture(gl.TEXTURE_2D, this.sgbFbTexture);
      gl.drawArrays(gl.TRIANGLE_STRIP, 8, 4);
      gl.disable(gl.BLEND);
    } else {
      gl.bindTexture(gl.TEXTURE_2D, this.fbTexture);
      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
    }
  }
}

class Rewind {
  constructor(module, e) {
    this.module = module;
    this.e = e;
    this.joypadBufferPtr = this.module._joypad_new();
    this.statePtr = 0;
    this.bufferPtr = this.module._rewind_new_simple(
      e,
      REWIND_FRAMES_PER_BASE_STATE,
      REWIND_BUFFER_CAPACITY
    );
    this.module._emulator_set_default_joypad_callback(e, this.joypadBufferPtr);
  }

  destroy() {
    this.module._rewind_delete(this.bufferPtr);
    this.module._joypad_delete(this.joypadBufferPtr);
  }

  get oldestTicks() {
    return this.module._rewind_get_oldest_ticks_f64(this.bufferPtr);
  }

  get newestTicks() {
    return this.module._rewind_get_newest_ticks_f64(this.bufferPtr);
  }

  pushBuffer() {
    if (!this.isRewinding) {
      this.module._rewind_append(this.bufferPtr, this.e);
    }
  }

  get isRewinding() {
    return this.statePtr !== 0;
  }

  beginRewind() {
    if (this.isRewinding) return;
    this.statePtr = this.module._rewind_begin(
      this.e,
      this.bufferPtr,
      this.joypadBufferPtr
    );
  }

  rewindToTicks(ticks) {
    if (!this.isRewinding) return;
    return (
      this.module._rewind_to_ticks_wrapper(this.statePtr, ticks) === RESULT_OK
    );
  }

  endRewind() {
    if (!this.isRewinding) return;
    this.module._emulator_set_default_joypad_callback(
      this.e,
      this.joypadBufferPtr
    );
    this.module._rewind_end(this.statePtr);
    this.statePtr = 0;
  }
}
