diff --git a/.gitignore b/.gitignore index 4a77473d4e2bfae5eb94e3479d68b23fb65466ec..be3d44f6d5fe02e8517f34a02305161496d55de9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,5 @@ Cargo.lock /.idea /target -/res/roms +/res/roms.prop /examples/*/target diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..61d9dd401ebff7dd541326508d9f257699fdb64b --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,112 @@ +image: hivesolutions/ubuntu_dev + +variables: + NETLIFY_SITE_ID: boytacean + NETLIFY_AUTH_TOKEN: $NETLIFY_AUTH_TOKEN + CLOUDFLARE_API_TOKEN: $CLOUDFLARE_API_TOKEN + CRATES_TOKEN: $CRATES_TOKEN + NPM_TOKEN: $NPM_TOKEN + +stages: + - build + - deploy + +before_script: + - apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y -q pkg-config + - curl -sf -L https://static.rust-lang.org/rustup.sh | sh -s -- -y + - export PATH=$PATH:$HOME/.cargo/bin + - curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.1/install.sh | bash + - export NVM_DIR="$HOME/.nvm" + - \[ -s "$NVM_DIR/nvm.sh" \] && \. "$NVM_DIR/nvm.sh" + - \[ -s "$NVM_DIR/bash_completion" \] && \. "$NVM_DIR/bash_completion" + - nvm install stable + +build-rust: + stage: build + parallel: + matrix: + - RUST_VERSION: ["1.56.1", "1.60.0", "stable", "nightly"] + script: + - rustup toolchain install $RUST_VERSION + - rustup override set $RUST_VERSION + - rustc --version + - cargo build + - cargo build --release + +build-wasm: + stage: build + parallel: + matrix: + - RUST_VERSION: ["1.60.0"] + script: + - rustup toolchain install $RUST_VERSION + - rustup override set $RUST_VERSION + - rustc --version + - cargo install wasm-pack + - wasm-pack build --release --target=web --out-dir=examples/web/lib -- --features wasm + - cd examples/web && npm install && npm run build + artifacts: + paths: + - examples/web/dist + - examples/web/lib + expire_in: 1 day + +deploy-netlify-preview: + stage: deploy + script: + - cd examples/web/dist + - npm_config_yes=true npx --package=netlify-cli netlify deploy --dir=. + dependencies: + - build-wasm + only: + - master + +deploy-netlify-prod: + stage: deploy + script: + - cd examples/web/dist + - npm_config_yes=true npx --package=netlify-cli netlify deploy --dir=. --prod + dependencies: + - build-wasm + only: + - tags + +deploy-cloudfare-preview: + stage: deploy + script: + - cd examples/web/dist + - npm_config_yes=true npx wrangler pages publish . --project-name=boytacean --branch master + dependencies: + - build-wasm + only: + - master + +deploy-cloudfare-prod: + stage: deploy + script: + - cd examples/web/dist + - npm_config_yes=true npx wrangler pages publish . --project-name=boytacean --branch stable + dependencies: + - build-wasm + only: + - tags + +deploy-crates: + stage: deploy + script: + - cargo login $CRATES_TOKEN + - cargo publish --no-verify + dependencies: + - build-rust + only: + - tags + +deploy-npm: + stage: deploy + script: + - echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > ~/.npmrc + - cd examples/web/lib && npm publish + dependencies: + - build-wasm + only: + - tags diff --git a/examples/sdl/src/main.rs b/examples/sdl/src/main.rs index 73969689f8a5d8e9089bf14fd57cdf4c02ef6853..50af56f892bf24ea07c23a6463d9d155910c058e 100644 --- a/examples/sdl/src/main.rs +++ b/examples/sdl/src/main.rs @@ -79,9 +79,9 @@ fn main() { .unwrap(); let mut game_boy = GameBoy::new(); - game_boy.load_boot_default(); - game_boy.load_rom("../../res/roms/ld_r_r.gb"); - //game_boy.load_rom("../../res/roms/opus5.gb"); + game_boy.load_boot_static(); + game_boy.load_rom_file("../../res/roms/firstwhite.gb"); + //game_boy.load_rom_file("../../res/roms/opus5.gb"); let mut counter = 0; diff --git a/examples/web/.gitignore b/examples/web/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5263f1b298966afe55b0447f940353959f789c55 --- /dev/null +++ b/examples/web/.gitignore @@ -0,0 +1,7 @@ +yarn.lock +package-lock.json + +/.parcel-cache + +/dist +/node_modules diff --git a/examples/web/.parcelrc b/examples/web/.parcelrc new file mode 100644 index 0000000000000000000000000000000000000000..1017d972ae95a264db5e230a4dc04e5cee3fc6ed --- /dev/null +++ b/examples/web/.parcelrc @@ -0,0 +1,7 @@ +{ + "extends": "@parcel/config-default", + "transformers": { + "*.{ts,tsx}": ["@parcel/transformer-typescript-tsc"], + "*.gb": ["@parcel/transformer-raw"] + } +} diff --git a/examples/web/.prettierrc b/examples/web/.prettierrc new file mode 100644 index 0000000000000000000000000000000000000000..6afb03785f6c8c93401f7b35905b6d8f8065e499 --- /dev/null +++ b/examples/web/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": true, + "trailingComma": "none", + "singleQuote": false, + "tabWidth": 4, + "endOfLine": "crlf" +} diff --git a/examples/web/index.css b/examples/web/index.css new file mode 100644 index 0000000000000000000000000000000000000000..decfc4d4079424e0ee6838a3700cf630f7e1fce7 --- /dev/null +++ b/examples/web/index.css @@ -0,0 +1,632 @@ +@import url("https://fonts.googleapis.com/css2?family=VT323&display=swap"); + +* { + box-sizing: border-box; + -o-box-sizing: border-box; + -ms-box-sizing: border-box; + -moz-box-sizing: border-box; + -khtml-box-sizing: border-box; + -webkit-box-sizing: border-box; +} + +a { + border-bottom: 2px dotted #ffffff; + color: #ffffff; + text-decoration: none; +} + +a:hover { + border-bottom-style: solid; +} + +html { + margin: 0px 0px 0px 0px; + padding: 0px 0px 0px 0px; +} + +body { + color: #ffffff; + font-family: "VT323", "Roboto", "Open Sans", Arial, Helvetica, sans-serif; + margin: 0px 0px 0px 0px; + padding: 12px 12px 52px 12px; +} + +p { + font-size: 18px; + line-height: 24px; + margin: 12px 0px 12px 0px; +} + +.main { + display: flex; +} + +@media only screen and (max-width: 1120px) { + .main { + flex-direction: column; + } +} + +.main > .side-left { + display: flex; + flex: 1 0; + justify-content: center; + text-align: center; +} + +.main > .side-left .canvas-container { + max-width: 100%; +} + +.main > .side-left .canvas-container.fullscreen { + align-items: center; + background-color: #2d2d2d; + display: flex; + height: 100%; + justify-content: center; + left: 0px; + position: fixed; + top: 0px; + width: 100%; + z-index: 6; +} + +.main > .side-left .canvas-container > .canvas-close { + bottom: 22px; + display: none; + position: absolute; + right: 22px; +} + +.main > .side-left .canvas-container > .canvas-close > img { + height: 32px; + width: 32px; +} + +.main > .side-left .canvas-container.fullscreen > .canvas-close { + display: block; +} + +.main > .side-left .canvas-container > .canvas-frame { + background-color: #1b1a17; + border: 2px solid #50cb93; + font-size: 0px; + margin-top: 78px; + max-width: 320px; + padding: 8px 8px 8px 8px; +} + +@media only screen and (max-width: 1120px) { + .main > .side-left .canvas-container > .canvas-frame { + margin-top: 12px; + } +} + +.main > .side-left .canvas-container.fullscreen > .canvas-frame { + background-color: transparent; + border: none; + box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); + -o-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); + -ms-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); + -moz-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); + -khtml-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); + -webkit-box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.24); + margin: 0px 0px 0px 0px; + max-width: unset; + padding: 0px 0px 0px 0px; +} + +.main > .side-left .canvas-container > .canvas-frame > .canvas { + width: 100%; +} + +.main > .side-right { + flex: 0 1; + max-width: 100%; + min-width: 580px; + padding: 0px 24px 0px 24px; +} + +@media only screen and (max-width: 1120px) { + .main > .side-right { + min-width: unset; + padding: 0px 0px 0px 0px; + } +} + +.main > .side-right .logo-image { + vertical-align: middle; + width: 32px; +} + +.main > .side-right .separator { + background: #ffffff; + height: 2px; + margin: 22px 0px 22px 0px; +} + +.main > .side-right .diag { + font-size: 24px; + vertical-align: top; +} + +.main > .side-right .diag > dt { + clear: both; + float: left; + margin-top: 12px; +} + +.main > .side-right .diag > dt:first-of-type { + margin-top: 0px; +} + +.main > .side-right .diag > dd { + float: right; + margin-top: 12px; +} + +.main > .side-right .diag > dd:first-of-type { + margin-top: 0px; +} + +.main > .side-right .diag::after { + clear: both; + content: ''; + display: block; +} + +.footer { + bottom: 0px; + height: 40px; + left: 0px; + line-height: 40px; + padding: 0px 0px 0px 0px; + position: fixed; + text-align: center; + width: 100%; +} + +.footer-background { + bottom: 0px; + filter: blur(1.0rem); + -o-filter: blur(1.0rem); + -ms-filter: blur(1.0rem); + -moz-filter: blur(1.0rem); + -khtml-filter: blur(1.0rem); + -webkit-filter: blur(1.0rem); + height: 40px; + left: 0px; + position: fixed; + width: 100%; +} + +.toast-container { + background-color: black; + height: 0px; + left: 0px; + padding: 0px 24px 0px 24px; + pointer-events: none; + position: fixed; + text-align: center; + top: 0px; + width: 100%; + z-index: 8; +} + +.toast-container > .toast { + background-color: #2a9d8f; + border-radius: 4px 4px 4px 4px; + -o-border-radius: 4px 4px 4px 4px; + -ms-border-radius: 4px 4px 4px 4px; + -moz-border-radius: 4px 4px 4px 4px; + -khtml-border-radius: 4px 4px 4px 4px; + -webkit-border-radius: 4px 4px 4px 4px; + cursor: pointer; + display: inline-block; + font-size: 20px; + line-height: 22px; + opacity: 0.0; + -o-opacity: 0.0; + -ms-opacity: 0.0; + -moz-opacity: 0.0; + -khtml-opacity: 0.0; + -webkit-opacity: 0.0; + padding: 12px 18px 12px 18px; + position: relative; + top: -46px; + transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: top 0.5s cubic-bezier(0.075, 0.82, 0.165, 1), opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + width: fit-content; +} + +.toast-container > .toast.error { + background-color: #e63946; +} + +.toast-container > .toast.visible { + opacity: 1.0; + -o-opacity: 1.0; + -ms-opacity: 1.0; + -moz-opacity: 1.0; + -khtml-opacity: 1.0; + -webkit-opacity: 1.0; + pointer-events: all; + top: 24px; +} + +.button-area { + user-select: none; + -o-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; +} + +.button-area > * { + margin-bottom: 12px; +} + +.magnify-button { + cursor: pointer; + display: inline-block; + transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); +} + +.magnify-button:hover { + transform: scale(1.3, 1.3); + -o-transform: scale(1.3, 1.3); + -ms-transform: scale(1.3, 1.3); + -moz-transform: scale(1.3, 1.3); + -khtml-transform: scale(1.3, 1.3); + -webkit-transform: scale(1.3, 1.3); +} + +.magnify-button:active { + transform: scale(1.0, 1.0); + -o-transform: scale(1.0, 1.0); + -ms-transform: scale(1.0, 1.0); + -moz-transform: scale(1.0, 1.0); + -khtml-transform: scale(1.0, 1.0); + -webkit-transform: scale(1.0, 1.0); +} + +.tiny-button { + border-radius: 96px 96px 96px 96px; + -o-border-radius: 96px 96px 96px 96px; + -ms-border-radius: 96px 96px 96px 96px; + -moz-border-radius: 96px 96px 96px 96px; + -khtml-border-radius: 96px 96px 96px 96px; + -webkit-border-radius: 96px 96px 96px 96px; + cursor: pointer; + display: inline-block; + padding: 0px 8px 0px 8px; + user-select: none; + -o-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; +} + +.tiny-button.border { + border: 1px solid #ffffff; +} + +.tiny-button.padded { + padding: 4px 10px 4px 10px; +} + +.tiny-button.padded-large { + padding: 4px 14px 4px 14px; +} + +.tiny-button.rounded { + padding: 6px 6px 6px 6px; +} + +.tiny-button.enabled { + background-color: #50cb93; +} + +.tiny-button.file { + position: relative; +} + +.tiny-button:hover { + background-color: #50cb93; +} + +.tiny-button.red:hover { + background-color: #e63946; +} + +.tiny-button:active { + background-color: #2a9d8f; +} + +.tiny-button.red:active { + background-color: #bf2a37; +} + +.tiny-button > img { + margin-right: 6px; + margin-top: 2px; + vertical-align: top; + width: 13px; +} + +.tiny-button > img.medium { + width: 20px; +} + +.tiny-button > img.large { + width: 28px; +} + +.tiny-button > img.very-large { + width: 38px; +} + +.tiny-button.no-text > img { + margin-right: 0px; + margin-top: 0px; +} + +.tiny-button.file > input[type="file"] { + cursor: pointer; + height: 100%; + left: 0px; + opacity: 0; + -o-opacity: 0; + -ms-opacity: 0; + -moz-opacity: 0; + -khtml-opacity: 0; + -webkit-opacity: 0; + position: absolute; + top: 0px; + vertical-align: top; + width: 100%; +} + +.tiny-button.file > input[type="file"]::-webkit-file-upload-button { + cursor: pointer; +} + +.overlay { + align-items: center; + background-color: rgba(80, 203, 147, 0.95); + display: flex; + font-size: 48px; + height: 100%; + justify-content: center; + left: 0px; + opacity: 0.0; + -o-opacity: 0.0; + -ms-opacity: 0.0; + -moz-opacity: 0.0; + -khtml-opacity: 0.0; + -webkit-opacity: 0.0; + pointer-events: none; + position: fixed; + text-align: center; + top: 0px; + transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + width: 100%; + z-index: 10; +} + +.overlay.visible { + opacity: 1.0; + -o-opacity: 1.0; + -ms-opacity: 1.0; + -moz-opacity: 1.0; + -khtml-opacity: 1.0; + -webkit-opacity: 1.0; +} + +.overlay .overlay-image { + margin-top: 16px; +} + +.overlay .overlay-image > img { + width: 64px; +} + +.modal-container { + align-items: center; + background-color: rgba(20, 20, 20, 0.95); + display: flex; + height: 100%; + justify-content: center; + left: 0px; + opacity: 0; + -o-opacity: 0; + -ms-opacity: 0; + -moz-opacity: 0; + -khtml-opacity: 0; + -webkit-opacity: 0; + padding: 0px 12px 0px 12px; + pointer-events: none; + position: fixed; + text-align: center; + top: 0px; + transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: opacity 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + width: 100%; + z-index: 10; +} + +.modal-container.visible { + opacity: 1.0; + -o-opacity: 1.0; + -ms-opacity: 1.0; + -moz-opacity: 1.0; + -khtml-opacity: 1.0; + -webkit-opacity: 1.0; + transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: opacity 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); +} + +.modal-container > .modal { + background-color: #264653; + border-radius: 6px 6px 6px 6px; + -o-border-radius: 6px 6px 6px 6px; + -ms-border-radius: 6px 6px 6px 6px; + -moz-border-radius: 6px 6px 6px 6px; + -khtml-border-radius: 6px 6px 6px 6px; + -webkit-border-radius: 6px 6px 6px 6px; + box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.24); + -o-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.24); + -ms-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.24); + -moz-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.24); + -khtml-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.24); + -webkit-box-shadow: 0px 3px 8px rgba(0, 0, 0, 0.24); + max-width: 100%; + padding: 24px 24px 24px 24px; + text-align: left; + transform: scale(0.96); + -o-transform: scale(0.96); + -ms-transform: scale(0.96); + -moz-transform: scale(0.96); + -khtml-transform: scale(0.96); + -webkit-transform: scale(0.96); + transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: transform 0.35s cubic-bezier(0.075, 0.82, 0.165, 1); + width: 480px; +} + +.modal-container.visible > .modal { + pointer-events: all; + transform: scale(1); + -o-transform: scale(1); + -ms-transform: scale(1); + -moz-transform: scale(1); + -khtml-transform: scale(1); + -webkit-transform: scale(1); + transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -o-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -ms-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -moz-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -khtml-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); + -webkit-transition: transform 0.5s cubic-bezier(0.075, 0.82, 0.165, 1); +} + +.modal-container > .modal .modal-top-buttons { + float: right; + margin-right: -10px; + margin-top: -10px; +} + +.modal-container > .modal .modal-title { + font-size: 32px; + margin-top: 0px; + text-align: left; +} + +.modal-container > .modal .modal-text { + font-size: 20px; + line-height: 22px; +} + +.modal-container > .modal .modal-buttons { + font-size: 22px; + margin-top: 24px; + text-align: center; + user-select: none; + -o-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; +} + +.modal-container > .modal .modal-buttons > .tiny-button { + margin-right: 12px; + min-width: 120px; +} + +.modal-container > .modal .modal-buttons > .tiny-button:last-child { + margin-right: 0px; +} + +.keyboard { + font-size: 0px; + text-align: center; + touch-callout: none; + -o-touch-callout: none; + -ms-touch-callout: none; + -moz-touch-callout: none; + -khtml-touch-callout: none; + -webkit-touch-callout: none; + user-select: none; + -o-user-select: none; + -ms-user-select: none; + -moz-user-select: none; + -khtml-user-select: none; + -webkit-user-select: none; +} + +.keyboard > .keyboard-line { + margin-bottom: 12px; +} + +.keyboard > .keyboard-line:last-child { + margin-bottom: 0px; +} + +.keyboard .key { + border: 2px solid #ffffff; + border-radius: 5px 5px 5px 5px; + -o-border-radius: 5px 5px 5px 5px; + -ms-border-radius: 5px 5px 5px 5px; + -moz-border-radius: 5px 5px 5px 5px; + -khtml-border-radius: 5px 5px 5px 5px; + -webkit-border-radius: 5px 5px 5px 5px; + cursor: pointer; + display: inline-block; + font-size: 38px; + height: 48px; + line-height: 46px; + margin-right: 14px; + text-align: center; + width: 48px; +} + +.keyboard .key:last-child { + margin-right: 0px; +} + +.keyboard .key:hover { + background-color: #50cb93; +} + +.keyboard .key:active { + background-color: #2a9d8f; +} diff --git a/examples/web/index.html b/examples/web/index.html new file mode 100644 index 0000000000000000000000000000000000000000..faa20ad4ca8c3c2eafbcfd30e29afb08fb62382b --- /dev/null +++ b/examples/web/index.html @@ -0,0 +1,158 @@ +<!DOCTYPE html> +<html lang="en"> + +<head> + <title>Boytacean</title> + <meta charset="utf-8"> + <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> + <meta name="description" content="Game Boy emulator written in Rust 🦀." /> + <meta name="viewport" + content="width=device-width, user-scalable=yes, initial-scale=1, minimum-scale=1, maximum-scale=5" /> + <link rel="icon" href="res/icon.png" /> + <link rel="stylesheet" href="index.css" /> +</head> + +<body> + <div class="main"> + <div class="side-left"> + <div id="canvas-container" class="canvas-container"> + <span id="canvas-close" class="magnify-button canvas-close"> + <img class="large" src="res/minimise.svg" alt="minimise" /> + </span> + <div class="canvas-frame"> + <canvas id="chip-canvas" class="canvas" width="320" height="288"></canvas> + </div> + </div> + </div> + <div class="side-right"> + <h1>Boytacean <a id="version" href="https://gitlab.stage.hive.pt/joamag/boytacean/-/blob/master/CHANGELOG.md" target="_blank"></a> <img class="logo-image" src="res/thunder.png" alt="thunder" /> + </h1> + <div class="separator"></div> + <div id="section-narrative" class="section"> + <p>This is a <a href="https://en.wikipedia.org/wiki/Game_Boy" target="_blank">Game Boy</a> emulator built using + the <a href="https://www.rust-lang.org" target="_blank">Rust Programming Language</a> and is running + inside this browser with the help of <a href="https://webassembly.org/" target="_blank">WebAssembly</a>. + </p> + <p>You can check the source code of it at <a href="https://gitlab.stage.hive.pt/joamag/boytacean" + target="_blank">GitLab</a>.</p> + <p>TIP: Drag and Drop ROM files to the Browser to load the ROM.</p> + </div> + <div id="separator-narrative" class="separator"></div> + <div id="section-keyboard" class="section" style="display: none;"> + <div id="keyboard" class="keyboard"> + <div class="keyboard-line"> + <span class="key">1</span> + <span class="key">2</span> + <span class="key">3</span> + <span class="key">4</span> + </div> + <div class="keyboard-line"> + <span class="key">Q</span> + <span class="key">W</span> + <span class="key">E</span> + <span class="key">R</span> + </div> + <div class="keyboard-line"> + <span class="key">A</span> + <span class="key">S</span> + <span class="key">D</span> + <span class="key">F</span> + </div> + <div class="keyboard-line"> + <span class="key">Z</span> + <span class="key">X</span> + <span class="key">C</span> + <span class="key">V</span> + </div> + </div> + </div> + <div id="separator-keyboard" class="separator" style="display: none;"></div> + <div id="section-diag" class="section"> + <dl class="diag"> + <dt>Engine</dt> + <dd id="engine" class="tiny-button">-</dd> + <dt>ROM</dt> + <dd id="rom-name">-</dd> + <dt>ROM Size</dt> + <dd><span id="rom-size">-</span> bytes</dd> + <dt>CPU Frequency</dt> + <dd> + <span id="logic-frequency-minus" class="tiny-button">-</span> + <span id="logic-frequency">-</span> Hz + <span id="logic-frequency-plus" class="tiny-button">+</span></dd> + <dt>Framerate</dt> + <dd><span id="fps-count">-</span> fps</dd> + </dl> + </div> + <div id="separator-diag" class="separator"></div> + <div class="section"> + <div class="button-area"> + <span id="button-pause" class="tiny-button border padded"> + <img src="res/pause.svg" alt="pause" /><span>Pause</span> + </span> + <span id="button-reset" class="tiny-button border padded"> + <img src="res/reset.svg" alt="reset" /><span>Reset</span> + </span> + <span id="button-benchmark" class="tiny-button border padded"> + <img src="res/bolt.svg" alt="bolt" /><span>Benchmark</span> + </span> + <span id="button-fullscreen" class="tiny-button border padded"> + <img src="res/maximise.svg" alt="maximise" /><span>Fullscreen</span> + </span> + <span id="button-keyboard" class="tiny-button border padded"> + <img src="res/dialpad.svg" alt="info" /><span>Keyboard</span> + </span> + <span id="button-information" class="tiny-button border padded enabled"> + <img src="res/info.svg" alt="info" /><span>Information</span> + </span> + <span id="button-debug" class="tiny-button border padded"> + <img src="res/bug.svg" alt="bug" /><span>Debug</span> + </span> + <span id="button-theme" class="tiny-button border padded"> + <img src="res/marker.svg" alt="marker" /><span>Theme</span> + </span> + <span id="button-upload" class="tiny-button border padded file"> + <img src="res/upload.svg" alt="upload" /><span>Upload ROM</span> + <input type="file" id="button-upload-file" name="button-upload-file" accept=".ch8"> + </span> + </div> + </div> + </div> + </div> + <div class="toast-container"> + <div id="toast" class="toast"></div> + </div> +</body> +<div id="modal-container" class="modal-container"> + <div id="modal" class="modal"> + <div class="modal-top-buttons"> + <span id="modal-close" class="tiny-button rounded no-text"> + <img class="medium" src="res/close.svg" alt="close" /> + </span> + </div> + <h2 id="modal-title" class="modal-title"></h2> + <p id="modal-text" class="modal-text"></p> + <div class="modal-buttons"> + <span id="modal-cancel" class="tiny-button red border padded-large">Cancel</span> + <span id="modal-confirm" class="tiny-button border padded-large">Confirm</span> + </div> + </div> +</div> +<div id="overlay" class="overlay"> + <div class="overlay-container"> + <div class="overlay-text"> + Drag to load ROM <span id="rom-name"></span> + </div> + <div class="overlay-image"> + <img src="res/sunglasses.png" alt="sunglasses" /> + </div> + </div> +</div> +<div id="footer-background" class="footer-background"></div> +<div id="footer" class="footer"> + Built with â¤ï¸ by <a href="https://joao.me" target="_blank">João Magalhães</a> +</div> +<script type="module" src="index.ts"></script> +</body> + +</html> diff --git a/examples/web/index.ts b/examples/web/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..68cd9aaa81b17b2aca0436a3fd2d004a8dc778ef --- /dev/null +++ b/examples/web/index.ts @@ -0,0 +1,896 @@ +import { default as wasm, GameBoy } from "./lib/boytacean.js"; +import info from "./package.json"; + +const PIXEL_UNSET_COLOR = 0x1b1a17ff; + +const LOGIC_HZ = 600; +const VISUAL_HZ = 60; +const TIMER_HZ = 60; +const IDLE_HZ = 10; + +const FREQUENCY_DELTA = 60; + +const DISPLAY_WIDTH = 160; +const DISPLAY_HEIGHT = 144; +const DISPLAY_RATIO = DISPLAY_WIDTH / DISPLAY_HEIGHT; + +const SAMPLE_RATE = 2; + +const SOUND_DATA = + "data:audio/mpeg;base64,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AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/7ksD/gAAAASwAAAAAAAAlgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/+5LA/4AAAAEsAAAAAAAAJYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uSwP+AAAABLAAAAAAAACWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/7ksD/gAAAASwAAAAAAAAlgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD/+5LA/4AAAAEsAAAAAAAAJYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//uSwP+AAAABLAAAAAAAACWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/7ksD/gAAAASwAAAAAAAAlgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAD/+5LA/4AAAAEsAAAAAAAAJYAAAAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAgACAAIAAA"; + +const BACKGROUNDS = [ + "264653", + "1b1a17", + "023047", + "bc6c25", + "283618", + "2a9d8f", + "3a5a40" +]; + +const KEYS: Record<string, number> = { + "1": 0x01, + "2": 0x02, + "3": 0x03, + "4": 0x0c, + q: 0x04, + w: 0x05, + e: 0x06, + r: 0x0d, + a: 0x07, + s: 0x08, + d: 0x09, + f: 0x0e, + z: 0x0a, + x: 0x00, + c: 0x0b, + v: 0x0f +}; + +// @ts-ignore: ts(2580) +const ROM_PATH = require("../../res/roms/firstwhite.gb"); + +type State = { + gameBoy: GameBoy; + engine: string; + logicFrequency: number; + visualFrequency: number; + timerFrequency: number; + idleFrequency: number; + canvas: HTMLCanvasElement; + canvasScaled: HTMLCanvasElement; + canvasCtx: CanvasRenderingContext2D; + canvasScaledCtx: CanvasRenderingContext2D; + image: ImageData; + videoBuff: DataView; + toastTimeout: number; + paused: boolean; + background_index: number; + nextTickTime: number; + fps: number; + frameStart: number; + frameCount: number; + romName: string; + romData: Uint8Array; + romSize: number; +}; + +type Global = { + modalCallback: Function; +}; + +const state: State = { + gameBoy: null, + engine: null, + logicFrequency: LOGIC_HZ, + visualFrequency: VISUAL_HZ, + timerFrequency: TIMER_HZ, + idleFrequency: IDLE_HZ, + canvas: null, + canvasScaled: null, + canvasCtx: null, + canvasScaledCtx: null, + image: null, + videoBuff: null, + toastTimeout: null, + paused: false, + background_index: 0, + nextTickTime: 0, + fps: 0, + frameStart: new Date().getTime(), + frameCount: 0, + romName: null, + romData: null, + romSize: 0 +}; + +const global: Global = { + modalCallback: null +}; + +const sound = ((data = SOUND_DATA, volume = 0.2) => { + const sound = new Audio(data); + sound.volume = volume; + sound.muted = true; + return sound; +})(); + +const main = async () => { + // initializes the WASM module, this is required + // so that the global symbols become available + await wasm(); + + // initializes the complete set of sub-systems + // and registers the event handlers + await init(); + await register(); + + // start the emulator subsystem with the initial + // ROM retrieved from a remote data source + await start({ loadRom: true }); + + // runs the sequence as an infinite loop, running + // the associated CPU cycles accordingly + while (true) { + // in case the machin is paused we must delay the execution + // a little bit until the paused state is recovered + if (state.paused) { + await new Promise((resolve) => { + setTimeout(resolve, 1000 / state.idleFrequency); + }); + continue; + } + + // obtains the current time, this value is going + // to be used to compute the need for tick computation + let currentTime = new Date().getTime(); + + try { + tick(currentTime); + } catch (err) { + // sets the default error message to be displayed + // to the user + 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 dianostics) + showToast(message, true, 5000); + console.error(err); + + // pauses the machine, allowing the end-user to act + // on the error in a proper fashion + pause(); + + // if we're talking about a panic proper action must be taken + // which in this case it means restarting both the WASM sub + // system and the machine state (to be able to recover) + // also sets the default color on screen to indicate the issue + if (isPanic) { + await clearCanvas(undefined, { + // @ts-ignore: ts(2580) + image: require("./res/storm.png"), + imageScale: 0.2 + }); + + await wasm(); + await start({ restore: false }); + } + } + + // calculates the amount of time until the next draw operation + // this is the amount of time that is going to be pending + currentTime = new Date().getTime(); + const pendingTime = Math.max(state.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); + }); + } +}; + +const tick = (currentTime: number) => { + // in case the time to draw the next frame has not been + // reached the flush of the "tick" logic is skiped + if (currentTime < state.nextTickTime) return; + + // 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 + if (state.nextTickTime === 0) state.nextTickTime = currentTime; + let ticks = Math.ceil( + (currentTime - state.nextTickTime) / + ((1 / state.visualFrequency) * 1000) + ); + ticks = Math.max(ticks, 1); + + let counterTicks = 0; + + while (true) { + // limits the number of ticks to the typical number + // of ticks required to do a complete PPU draw + if (counterTicks >= 70224) { + break; + } + + // runs the Game Boy clock, this operations should + // include the advance of both the CPU and the PPU + counterTicks += state.gameBoy.clock(); + } + + // updates the canvas object with the new + // visual information coming in + updateCanvas(state.gameBoy.frame_buffer_eager()); + + // increments the number of frames rendered in the current + // section, this value is going to be used to calculate FPS + state.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 (state.frameCount === state.visualFrequency * SAMPLE_RATE) { + const currentTime = new Date().getTime(); + const deltaTime = (currentTime - state.frameStart) / 1000; + const fps = Math.round(state.frameCount / deltaTime); + setFps(fps); + state.frameCount = 0; + state.frameStart = currentTime; + } + + // updates the next update time reference to the, so that it + // can be used to control the game loop + state.nextTickTime += (1000 / state.visualFrequency) * ticks; +}; + +const start = async ({ + engine = "neo", + restore = true, + loadRom = false, + romPath = ROM_PATH, + romName = null as string, + romData = null as Uint8Array +} = {}) => { + // in case a remote ROM loading operation has been + // requested then loads it from the remote origin + if (loadRom) { + [romName, romData] = await fetchRom(romPath); + } else if (romName === null || romData === null) { + [romName, romData] = [state.romName, state.romData]; + } + + // selects the proper engine for execution + // and builds a new instance of it + switch (engine) { + case "neo": + state.gameBoy = new GameBoy(); + break; + + default: + if (!state.gameBoy) { + throw new Error("No engine requested"); + } + break; + } + + // resets the Game Boy engine to restore it into + // a valid state ready to be used + //state.gameBoy.reset_hard(); @todo + state.gameBoy.load_boot_static(); + state.gameBoy.load_rom(romData); + + // updates the name of the currently selected engine + // to the one that has been provided (logic change) + if (engine) state.engine = engine; + + // updates the complete set of global information that + // is going to be displayed + setEngine(state.engine); + setRom(romName, romData); + setLogicFrequency(state.logicFrequency); + setFps(state.fps); + + // in case the restore (state) flag is set + // then resumes the machine execution + if (restore) resume(); +}; + +const register = async () => { + await Promise.all([ + registerDrop(), + registerKeys(), + registerButtons(), + registerKeyboard(), + registerCanvas(), + registerToast(), + registerModal() + ]); +}; + +const init = async () => { + await Promise.all([initBase(), initCanvas()]); +}; + +const registerDrop = () => { + document.addEventListener("drop", async (event) => { + if ( + !event.dataTransfer.files || + event.dataTransfer.files.length === 0 + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const overlay = document.getElementById("overlay"); + overlay.classList.remove("visible"); + + const file = event.dataTransfer.files[0]; + + if (!file.name.endsWith(".gb")) { + showToast("This is probably not a Game Boy ROM file!", true); + return; + } + + const arrayBuffer = await file.arrayBuffer(); + const romData = new Uint8Array(arrayBuffer); + + start({ engine: null, romName: file.name, romData: romData }); + + showToast(`Loaded ${file.name} ROM successfully!`); + }); + document.addEventListener("dragover", async (event) => { + if (!event.dataTransfer.items || event.dataTransfer.items[0].type) + return; + + event.preventDefault(); + + const overlay = document.getElementById("overlay"); + overlay.classList.add("visible"); + }); + document.addEventListener("dragenter", async (event) => { + if (!event.dataTransfer.items || event.dataTransfer.items[0].type) + return; + const overlay = document.getElementById("overlay"); + overlay.classList.add("visible"); + }); + document.addEventListener("dragleave", async (event) => { + if (!event.dataTransfer.items || event.dataTransfer.items[0].type) + return; + const overlay = document.getElementById("overlay"); + overlay.classList.remove("visible"); + }); +}; + +const registerKeys = () => { + document.addEventListener("keydown", (event) => { + const keyCode = KEYS[event.key]; + if (keyCode !== undefined) { + //state.gameBoy.key_press_ws(keyCode); @todo + return; + } + + switch (event.key) { + case "+": + setLogicFrequency(state.logicFrequency + FREQUENCY_DELTA); + break; + + case "-": + setLogicFrequency(state.logicFrequency - FREQUENCY_DELTA); + break; + + case "Escape": + minimize(); + break; + } + }); + + document.addEventListener("keyup", (event) => { + const keyCode = KEYS[event.key]; + if (keyCode !== undefined) { + //state.gameBoy.key_lift_ws(keyCode); @todo + return; + } + }); +}; + +const registerButtons = () => { + const engine = document.getElementById("engine"); + engine.addEventListener("click", () => { + const name = state.engine == "neo" ? "classic" : "neo"; + start({ engine: name }); + showToast( + `Game Boy running in engine "${name.toUpperCase()}" from now on!` + ); + }); + + const logicFrequencyPlus = document.getElementById("logic-frequency-plus"); + logicFrequencyPlus.addEventListener("click", () => { + setLogicFrequency(state.logicFrequency + FREQUENCY_DELTA); + }); + + const logicFrequencyMinus = document.getElementById( + "logic-frequency-minus" + ); + logicFrequencyMinus.addEventListener("click", () => { + setLogicFrequency(state.logicFrequency - FREQUENCY_DELTA); + }); + + const buttonPause = document.getElementById("button-pause"); + buttonPause.addEventListener("click", () => { + toggleRunning(); + }); + + const buttonReset = document.getElementById("button-reset"); + buttonReset.addEventListener("click", () => { + reset(); + }); + + const buttonBenchmark = document.getElementById("button-benchmark"); + buttonBenchmark.addEventListener("click", async () => { + const result = await showModal( + "Are you sure you want to start a benchmark?\nThe benchmark is considered an expensive operation!", + "Confirm" + ); + if (!result) return; + buttonBenchmark.classList.add("enabled"); + pause(); + try { + const initial = Date.now(); + const count = 500000000; + for (let i = 0; i < count; i++) { + state.gameBoy.clock(); + } + const delta = (Date.now() - initial) / 1000; + const frequency_mhz = count / delta / 1000 / 1000; + showToast( + `Took ${delta.toFixed( + 2 + )} seconds to run ${count} ticks (${frequency_mhz.toFixed( + 2 + )} Mhz)!`, + undefined, + 7500 + ); + } finally { + resume(); + buttonBenchmark.classList.remove("enabled"); + } + }); + + const buttonFullscreen = document.getElementById("button-fullscreen"); + buttonFullscreen.addEventListener("click", () => { + maximize(); + }); + + const buttonKeyboard = document.getElementById("button-keyboard"); + buttonKeyboard.addEventListener("click", () => { + const sectionKeyboard = document.getElementById("section-keyboard"); + const separatorKeyboard = document.getElementById("separator-keyboard"); + const sectionNarrative = document.getElementById("section-narrative"); + const separatorNarrative = document.getElementById( + "separator-narrative" + ); + if (buttonKeyboard.classList.contains("enabled")) { + sectionKeyboard.style.display = "none"; + separatorKeyboard.style.display = "none"; + sectionNarrative.style.display = "block"; + separatorNarrative.style.display = "block"; + buttonKeyboard.classList.remove("enabled"); + } else { + sectionKeyboard.style.display = "block"; + separatorKeyboard.style.display = "block"; + sectionNarrative.style.display = "none"; + separatorNarrative.style.display = "none"; + buttonKeyboard.classList.add("enabled"); + } + }); + + const buttonInformation = document.getElementById("button-information"); + buttonInformation.addEventListener("click", () => { + const sectionDiag = document.getElementById("section-diag"); + const separatorDiag = document.getElementById("separator-diag"); + if (buttonInformation.classList.contains("enabled")) { + sectionDiag.style.display = "none"; + separatorDiag.style.display = "none"; + buttonInformation.classList.remove("enabled"); + } else { + sectionDiag.style.display = "block"; + separatorDiag.style.display = "block"; + buttonInformation.classList.add("enabled"); + } + }); + + const buttonTheme = document.getElementById("button-theme"); + buttonTheme.addEventListener("click", () => { + state.background_index = + (state.background_index + 1) % BACKGROUNDS.length; + const background = BACKGROUNDS[state.background_index]; + setBackground(background); + }); + + const buttonUploadFile = document.getElementById( + "button-upload-file" + ) as HTMLInputElement; + buttonUploadFile.addEventListener("change", async () => { + if (!buttonUploadFile.files || buttonUploadFile.files.length === 0) { + return; + } + + const file = buttonUploadFile.files[0]; + + const arrayBuffer = await file.arrayBuffer(); + const romData = new Uint8Array(arrayBuffer); + + buttonUploadFile.value = ""; + + start({ engine: null, romName: file.name, romData: romData }); + + showToast(`Loaded ${file.name} ROM successfully!`); + }); +}; + +const registerKeyboard = () => { + const keyboard = document.getElementById("keyboard"); + const keys = keyboard.getElementsByClassName("key"); + + keyboard.addEventListener("touchstart", function (event) { + event.preventDefault(); + event.stopPropagation(); + }); + + keyboard.addEventListener("touchend", function (event) { + event.preventDefault(); + event.stopPropagation(); + }); + + Array.prototype.forEach.call(keys, (k: Element) => { + k.addEventListener("mousedown", function (event) { + const keyCode = KEYS[this.textContent.toLowerCase()]; + //state.gameBoy.key_press_ws(keyCode); @todo + event.preventDefault(); + event.stopPropagation(); + }); + + k.addEventListener("touchstart", function (event) { + const keyCode = KEYS[this.textContent.toLowerCase()]; + //state.gameBoy.key_press_ws(keyCode); @todo + event.preventDefault(); + event.stopPropagation(); + }); + + k.addEventListener("mouseup", function (event) { + const keyCode = KEYS[this.textContent.toLowerCase()]; + //state.gameBoy.key_lift_ws(keyCode); @todo + event.preventDefault(); + event.stopPropagation(); + }); + + k.addEventListener("touchend", function (event) { + const keyCode = KEYS[this.textContent.toLowerCase()]; + //state.gameBoy.key_lift_ws(keyCode); @todo + event.preventDefault(); + event.stopPropagation(); + }); + }); +}; + +const registerCanvas = () => { + const canvasClose = document.getElementById("canvas-close"); + canvasClose.addEventListener("click", () => { + minimize(); + }); +}; + +const registerToast = () => { + const toast = document.getElementById("toast"); + toast.addEventListener("click", () => { + toast.classList.remove("visible"); + }); +}; + +const registerModal = () => { + const modalClose = document.getElementById("modal-close"); + modalClose.addEventListener("click", () => { + hideModal(false); + }); + + const modalCancel = document.getElementById("modal-cancel"); + modalCancel.addEventListener("click", () => { + hideModal(false); + }); + + const modalConfirm = document.getElementById("modal-confirm"); + modalConfirm.addEventListener("click", () => { + hideModal(true); + }); + + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + hideModal(false); + } + }); +}; + +const initBase = async () => { + const background = BACKGROUNDS[state.background_index]; + setBackground(background); + setVersion(info.version); +}; + +const initCanvas = async () => { + // initializes the off-screen canvas that is going to be + // used in the drawing process + state.canvas = document.createElement("canvas"); + state.canvas.width = DISPLAY_WIDTH; + state.canvas.height = DISPLAY_HEIGHT; + state.canvasCtx = state.canvas.getContext("2d"); + + state.canvasScaled = document.getElementById( + "chip-canvas" + ) as HTMLCanvasElement; + state.canvasScaled.width = + state.canvasScaled.width * window.devicePixelRatio; + state.canvasScaled.height = + state.canvasScaled.height * window.devicePixelRatio; + state.canvasScaledCtx = state.canvasScaled.getContext("2d"); + + state.canvasScaledCtx.scale( + state.canvasScaled.width / state.canvas.width, + state.canvasScaled.height / state.canvas.height + ); + state.canvasScaledCtx.imageSmoothingEnabled = false; + + state.image = state.canvasCtx.createImageData( + state.canvas.width, + state.canvas.height + ); + state.videoBuff = new DataView(state.image.data.buffer); +}; + +const updateCanvas = (pixels: Uint8Array) => { + let offset = 0; + for (let index = 0; index < pixels.length; index += 3) { + const color = + (pixels[index] << 24) | + (pixels[index + 1] << 16) | + (pixels[index + 2] << 8) | + 0xff; + state.videoBuff.setUint32(offset, color); + offset += 4; + } + state.canvasCtx.putImageData(state.image, 0, 0); + state.canvasScaledCtx.drawImage(state.canvas, 0, 0); +}; + +const clearCanvas = async ( + color = PIXEL_UNSET_COLOR, + { image = null as string, imageScale = 1 } = {} +) => { + state.canvasScaledCtx.fillStyle = `#${color.toString(16).toUpperCase()}`; + state.canvasScaledCtx.fillRect( + 0, + 0, + state.canvasScaled.width, + state.canvasScaled.height + ); + + // in case an image was requested then uses that to load + // an image at the center of the screen + if (image) { + const img = await new Promise<HTMLImageElement>((resolve) => { + const img = new Image(); + img.onload = () => { + resolve(img); + }; + img.src = image; + }); + const [imgWidth, imgHeight] = [ + img.width * imageScale * window.devicePixelRatio, + img.height * imageScale * window.devicePixelRatio + ]; + const [x0, y0] = [ + state.canvasScaled.width / 2 - imgWidth / 2, + state.canvasScaled.height / 2 - imgHeight / 2 + ]; + state.canvasScaledCtx.setTransform(1, 0, 0, 1, 0, 0); + try { + state.canvasScaledCtx.drawImage(img, x0, y0, imgWidth, imgHeight); + } finally { + state.canvasScaledCtx.scale( + state.canvasScaled.width / state.canvas.width, + state.canvasScaled.height / state.canvas.height + ); + } + } +}; + +const showToast = async (message: string, error = false, timeout = 3500) => { + const toast = document.getElementById("toast"); + toast.classList.remove("error"); + if (error) toast.classList.add("error"); + toast.classList.add("visible"); + toast.textContent = message; + if (state.toastTimeout) clearTimeout(state.toastTimeout); + state.toastTimeout = setTimeout(() => { + toast.classList.remove("visible"); + state.toastTimeout = null; + }, timeout); +}; + +const showModal = async ( + message: string, + title = "Alert" +): Promise<boolean> => { + const modalContainer = document.getElementById("modal-container"); + const modalTitle = document.getElementById("modal-title"); + const modalText = document.getElementById("modal-text"); + modalContainer.classList.add("visible"); + modalTitle.textContent = title; + modalText.innerHTML = message.replace(/\n/g, "<br/>"); + const result = (await new Promise((resolve) => { + global.modalCallback = resolve; + })) as boolean; + return result; +}; + +const hideModal = async (result = true) => { + const modalContainer = document.getElementById("modal-container"); + modalContainer.classList.remove("visible"); + if (global.modalCallback) global.modalCallback(result); + global.modalCallback = null; +}; + +const setVersion = (value: string) => { + document.getElementById("version").textContent = value; +}; + +const setEngine = (name: string, upper = true) => { + name = upper ? name.toUpperCase() : name; + document.getElementById("engine").textContent = name; +}; + +const setRom = (name: string, data: Uint8Array) => { + state.romName = name; + state.romData = data; + state.romSize = data.length; + document.getElementById("rom-name").textContent = name; + document.getElementById("rom-size").textContent = String(data.length); +}; + +const setLogicFrequency = (value: number) => { + if (value < 0) showToast("Invalid frequency value!", true); + value = Math.max(value, 0); + state.logicFrequency = value; + document.getElementById("logic-frequency").textContent = String(value); +}; + +const setFps = (value: number) => { + if (value < 0) showToast("Invalid FPS value!", true); + value = Math.max(value, 0); + state.fps = value; + document.getElementById("fps-count").textContent = String(value); +}; + +const setBackground = (value: string) => { + document.body.style.backgroundColor = `#${value}`; + document.getElementById( + "footer-background" + ).style.backgroundColor = `#${value}f2`; +}; + +const toggleRunning = () => { + if (state.paused) { + resume(); + } else { + pause(); + } +}; + +const pause = () => { + state.paused = true; + const buttonPause = document.getElementById("button-pause"); + const img = buttonPause.getElementsByTagName("img")[0]; + const span = buttonPause.getElementsByTagName("span")[0]; + buttonPause.classList.add("enabled"); + // @ts-ignore: ts(2580) + img.src = require("./res/play.svg"); + span.textContent = "Resume"; +}; + +const resume = () => { + state.paused = false; + state.nextTickTime = new Date().getTime(); + const buttonPause = document.getElementById("button-pause"); + const img = buttonPause.getElementsByTagName("img")[0]; + const span = buttonPause.getElementsByTagName("span")[0]; + buttonPause.classList.remove("enabled"); + // @ts-ignore: ts(2580) + img.src = require("./res/pause.svg"); + span.textContent = "Pause"; +}; + +const toggleWindow = () => { + maximize(); +}; + +const maximize = () => { + const canvasContainer = document.getElementById("canvas-container"); + canvasContainer.classList.add("fullscreen"); + + window.addEventListener("resize", crop); + + crop(); +}; + +const minimize = () => { + const canvasContainer = document.getElementById("canvas-container"); + const chipCanvas = document.getElementById("chip-canvas"); + canvasContainer.classList.remove("fullscreen"); + chipCanvas.style.width = null; + chipCanvas.style.height = null; + window.removeEventListener("resize", crop); +}; + +const crop = () => { + const chipCanvas = document.getElementById("chip-canvas"); + + // calculates the window ratio as this is fundamental to + // determine the proper way to crop the fulscreen + const windowRatio = window.innerWidth / window.innerHeight; + + // in case the window is wider (more horizontal than the base ratio) + // this means that we must crop horizontaly + if (windowRatio > DISPLAY_RATIO) { + chipCanvas.style.width = `${ + window.innerWidth * (DISPLAY_RATIO / windowRatio) + }px`; + chipCanvas.style.height = `${window.innerHeight}px`; + } else { + chipCanvas.style.width = `${window.innerWidth}px`; + chipCanvas.style.height = `${ + window.innerHeight * (windowRatio / DISPLAY_RATIO) + }px`; + } +}; + +const reset = () => { + start({ engine: null }); +}; + +const fetchRom = async (romPath: string): Promise<[string, Uint8Array]> => { + // extracts the name of the ROM from the provided + // path by splitting its structure + const romPathS = romPath.split(/\//g); + let romName = romPathS[romPathS.length - 1].split("?")[0]; + const romNameS = romName.split(/\./g); + romName = `${romNameS[0]}.${romNameS[romNameS.length - 1]}`; + + // loads the ROM data and converts it into the + // target byte array buffer (to be used by WASM) + const response = await fetch(ROM_PATH); + const blob = await response.blob(); + const arrayBuffer = await blob.arrayBuffer(); + const romData = new Uint8Array(arrayBuffer); + + // returns both the name of the ROM and the data + // contents as a byte array + return [romName, romData]; +}; + +(async () => { + await main(); +})(); diff --git a/examples/web/package.json b/examples/web/package.json new file mode 100644 index 0000000000000000000000000000000000000000..6130b967919cbfd162bb51f0a20e95f588e63216 --- /dev/null +++ b/examples/web/package.json @@ -0,0 +1,24 @@ +{ + "name": "boytacean-web", + "version": "0.1.0", + "description": "The web version of Boytacean", + "repository": { + "type": "git", + "url": "git+https://gitlab.stage.hive.pt/joamag/boytacean.git" + }, + "license": "Apache-2.0", + "scripts": { + "build": "parcel build index.html", + "dev": "parcel index.html", + "pretty": "prettier --config .prettierrc \"./**/*.{ts,json}\" --write", + "start": "npm run build", + "watch": "parcel watch index.html" + }, + "source": "index.ts", + "devDependencies": { + "@parcel/transformer-typescript-tsc": "^2.6.1", + "parcel": "^2.6.1", + "prettier": "^2.7.1", + "typescript": "^4.5.5" + } +} diff --git a/examples/web/res/bike.svg b/examples/web/res/bike.svg new file mode 100644 index 0000000000000000000000000000000000000000..ee11c9aef5bf3aec5fa590cfdedad74ed781ab68 --- /dev/null +++ b/examples/web/res/bike.svg @@ -0,0 +1 @@ +<svg width="48px" height="48px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-labelledby="bikeIconTitle" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" color="#ffffff"> <title id="bikeIconTitle">Bike</title> <circle cx="14" cy="6" r="1"/> <path d="M12 18V14L9 12L12 9L14 11L16 12"/> <circle cx="6" cy="17" r="3"/> <circle cx="18" cy="17" r="3"/> </svg> \ No newline at end of file diff --git a/examples/web/res/bolt.svg b/examples/web/res/bolt.svg new file mode 100644 index 0000000000000000000000000000000000000000..9bbaca340c017181bb2542e13eccf234c9937ae4 --- /dev/null +++ b/examples/web/res/bolt.svg @@ -0,0 +1 @@ +<svg role="img" xmlns="http://www.w3.org/2000/svg" width="48px" height="48px" viewBox="0 0 24 24" aria-labelledby="boltIconTitle" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" fill="none" color="#ffffff"> <title id="boltIconTitle">Bolt</title> <path d="M5 14l8-11v7h5l-8 11v-7z"/> </svg> \ No newline at end of file diff --git a/examples/web/res/bug.svg b/examples/web/res/bug.svg new file mode 100644 index 0000000000000000000000000000000000000000..db1713a9528be8cf8ae1e94d92ebf55c80d53f4e --- /dev/null +++ b/examples/web/res/bug.svg @@ -0,0 +1 @@ +<svg role="img" xmlns="http://www.w3.org/2000/svg" width="48px" height="48px" viewBox="0 0 24 24" aria-labelledby="bugIconTitle" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" fill="none" color="#ffffff"> <title id="bugIconTitle">Bug</title> <path d="M15 6.99989086C16.1045695 6.99989086 17 7.89532136 17 8.99989086L17 16.458686C17 17.1113133 16.6815784 17.722892 16.1469254 18.0971494L12 21 7.85307456 18.0971494C7.31842164 17.722892 7 17.1113133 7 16.458686L7 8.99989086C7 7.89532136 7.8954305 6.99989086 9 6.99989086 9.00005899 5.34308677 10.3431821 4 12 4 13.6568179 4 14.999941 5.34308677 15 6.99989086zM4 13L7 13"/> <polyline points="3 7 5 9 7 9"/> <polyline points="21 7 19 9 17 9"/> <polyline points="3 19 5 17 7 17"/> <polyline points="17 17 19 17 21 19 21 19"/> <path d="M17,13 L20,13"/> </svg> \ No newline at end of file diff --git a/examples/web/res/close.svg b/examples/web/res/close.svg new file mode 100644 index 0000000000000000000000000000000000000000..aeac9982d889f0194b3b55905d166541704b5bac --- /dev/null +++ b/examples/web/res/close.svg @@ -0,0 +1 @@ +<svg role="img" xmlns="http://www.w3.org/2000/svg" width="48px" height="48px" viewBox="0 0 24 24" aria-labelledby="closeIconTitle" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" fill="none" color="#ffffff"> <title id="closeIconTitle">Close</title> <path d="M6.34314575 6.34314575L17.6568542 17.6568542M6.34314575 17.6568542L17.6568542 6.34314575"/> </svg> \ No newline at end of file diff --git a/examples/web/res/dialpad.svg b/examples/web/res/dialpad.svg new file mode 100644 index 0000000000000000000000000000000000000000..bc3a286d40081aefb228a3225fc36c8f8457443e --- /dev/null +++ b/examples/web/res/dialpad.svg @@ -0,0 +1 @@ +<svg width="48px" height="48px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-labelledby="dialpadIconTitle" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" fill="none" color="#ffffff"> <title id="dialpadIconTitle">Dialpad</title> <circle cx="7" cy="5" r="1"/> <circle cx="12" cy="5" r="1"/> <circle cx="17" cy="5" r="1"/> <circle cx="7" cy="10" r="1"/> <circle cx="12" cy="10" r="1"/> <circle cx="17" cy="10" r="1"/> <circle cx="7" cy="15" r="1"/> <circle cx="12" cy="15" r="1"/> <circle cx="12" cy="20" r="1"/> <circle cx="17" cy="15" r="1"/> </svg> \ No newline at end of file diff --git a/examples/web/res/icon.png b/examples/web/res/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e0d2d61e8466ac6c165cfcd72cd169ff341e190d Binary files /dev/null and b/examples/web/res/icon.png differ diff --git a/examples/web/res/info.svg b/examples/web/res/info.svg new file mode 100644 index 0000000000000000000000000000000000000000..f9b1fc185b43d86e783a349d07e7e2bd3bd2156f --- /dev/null +++ b/examples/web/res/info.svg @@ -0,0 +1 @@ +<svg role="img" xmlns="http://www.w3.org/2000/svg" width="48px" height="48px" viewBox="0 0 24 24" aria-labelledby="infoIconTitle" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" fill="none" color="#ffffff"> <title id="infoIconTitle">Information</title> <path d="M12,12 L12,15"/> <line x1="12" y1="9" x2="12" y2="9"/> <circle cx="12" cy="12" r="10"/> </svg> \ No newline at end of file diff --git a/examples/web/res/marker.svg b/examples/web/res/marker.svg new file mode 100644 index 0000000000000000000000000000000000000000..461dcb53619534e4ab0d6e5ab417e0eacba74559 --- /dev/null +++ b/examples/web/res/marker.svg @@ -0,0 +1 @@ +<svg width="48px" height="48px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-labelledby="markerIconTitle" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" color="#ffffff"> <title id="markerIconTitle">Marker</title> <path fill-rule="evenodd" clip-rule="evenodd" d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z"/> <path d="M6 20L8 13H16L18 20"/> <path d="M9.5 13L11.0299 6.88057C11.2823 5.87062 12.7177 5.87062 12.9701 6.88057L14.5 13"/> </svg> \ No newline at end of file diff --git a/examples/web/res/maximise.svg b/examples/web/res/maximise.svg new file mode 100644 index 0000000000000000000000000000000000000000..0ce8e6ad748612c05f28d736ee7ee31d4b706f54 --- /dev/null +++ b/examples/web/res/maximise.svg @@ -0,0 +1 @@ +<svg role="img" xmlns="http://www.w3.org/2000/svg" width="48px" height="48px" viewBox="0 0 24 24" aria-labelledby="maximiseIconTitle" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" fill="none" color="#ffffff"> <title id="maximiseIconTitle">Maximise View</title> <polyline points="21 16 21 21 16 21"/> <polyline points="8 21 3 21 3 16"/> <polyline points="16 3 21 3 21 8"/> <polyline points="3 8 3 3 8 3"/> </svg> \ No newline at end of file diff --git a/examples/web/res/minimise.svg b/examples/web/res/minimise.svg new file mode 100644 index 0000000000000000000000000000000000000000..3d41ee133bf7a84a220f8649695c5fdc0a9fdb31 --- /dev/null +++ b/examples/web/res/minimise.svg @@ -0,0 +1 @@ +<svg role="img" xmlns="http://www.w3.org/2000/svg" width="48px" height="48px" viewBox="0 0 24 24" aria-labelledby="minimiseIconTitle" stroke="#ffffff" stroke-width="3" stroke-linecap="square" stroke-linejoin="miter" fill="none" color="#ffffff"> <title id="minimiseIconTitle">Minimise View</title> <polyline points="8 3 8 8 3 8"/> <polyline points="21 8 16 8 16 3"/> <polyline points="3 16 8 16 8 21"/> <polyline points="16 21 16 16 21 16"/> </svg> \ No newline at end of file diff --git a/examples/web/res/pause.svg b/examples/web/res/pause.svg new file mode 100644 index 0000000000000000000000000000000000000000..768f80a6eef920d2decafd4b64e3ee22fcda0a9f --- /dev/null +++ b/examples/web/res/pause.svg @@ -0,0 +1 @@ +<svg role="img" xmlns="http://www.w3.org/2000/svg" width="48px" height="48px" viewBox="0 0 24 24" aria-labelledby="pauseIconTitle" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" fill="none" color="#ffffff"> <title id="pauseIconTitle">Pause</title> <rect width="4" height="16" x="5" y="4"/> <rect width="4" height="16" x="15" y="4"/> </svg> \ No newline at end of file diff --git a/examples/web/res/play.svg b/examples/web/res/play.svg new file mode 100644 index 0000000000000000000000000000000000000000..647e1f5af90b09975ab0feaf300c87ed355307bf --- /dev/null +++ b/examples/web/res/play.svg @@ -0,0 +1 @@ +<svg role="img" xmlns="http://www.w3.org/2000/svg" width="48px" height="48px" viewBox="0 0 24 24" aria-labelledby="playIconTitle" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" fill="none" color="#ffffff"> <title id="playIconTitle">Play</title> <path d="M20 12L5 21V3z"/> </svg> \ No newline at end of file diff --git a/examples/web/res/reset.svg b/examples/web/res/reset.svg new file mode 100644 index 0000000000000000000000000000000000000000..31c7867465102246e497a50ed9d3e7dc0fc24581 --- /dev/null +++ b/examples/web/res/reset.svg @@ -0,0 +1 @@ +<svg role="img" xmlns="http://www.w3.org/2000/svg" width="48px" height="48px" viewBox="0 0 24 24" aria-labelledby="refreshIconTitle" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" fill="none" color="#ffffff"> <title id="refreshIconTitle">Refresh</title> <polyline points="22 12 19 15 16 12"/> <path d="M11,20 C6.581722,20 3,16.418278 3,12 C3,7.581722 6.581722,4 11,4 C15.418278,4 19,7.581722 19,12 L19,14"/> </svg> \ No newline at end of file diff --git a/examples/web/res/storm.png b/examples/web/res/storm.png new file mode 100644 index 0000000000000000000000000000000000000000..afcff7338dad180df01cfab7bffc39a85ffc50e4 Binary files /dev/null and b/examples/web/res/storm.png differ diff --git a/examples/web/res/sunglasses.png b/examples/web/res/sunglasses.png new file mode 100644 index 0000000000000000000000000000000000000000..98b0bba649db0699a170016e18bc4b7a9ce3b70c Binary files /dev/null and b/examples/web/res/sunglasses.png differ diff --git a/examples/web/res/thunder.png b/examples/web/res/thunder.png new file mode 100644 index 0000000000000000000000000000000000000000..409773908ec7d835778393e7de7c2559fd366672 Binary files /dev/null and b/examples/web/res/thunder.png differ diff --git a/examples/web/res/upload.svg b/examples/web/res/upload.svg new file mode 100644 index 0000000000000000000000000000000000000000..99911c5c39937def74fb39f855cce6ddd50e876e --- /dev/null +++ b/examples/web/res/upload.svg @@ -0,0 +1 @@ +<svg role="img" xmlns="http://www.w3.org/2000/svg" width="48px" height="48px" viewBox="0 0 24 24" aria-labelledby="uploadIconTitle" stroke="#ffffff" stroke-width="2" stroke-linecap="square" stroke-linejoin="miter" fill="none" color="#ffffff"> <title id="uploadIconTitle">Upload</title> <path d="M12,4 L12,17"/> <polyline points="7 8 12 3 17 8"/> <path d="M20,21 L4,21"/> </svg> \ No newline at end of file diff --git a/examples/web/tsconfig.json b/examples/web/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..0bc8cec0b5a82f53019127e9adb2821060aaa7dd --- /dev/null +++ b/examples/web/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "module": "es2015", + "moduleResolution": "node", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "target": "es6", + "noImplicitAny": true, + "sourceMap": true, + "outDir": ".", + "baseUrl": ".", + "lib": ["es2015", "dom"], + "paths": { + "*": ["node_modules/*", "src/types/*"] + } + }, + "include": ["**/*"], + "exclude": [] +} diff --git a/res/roms/firstwhite.gb b/res/roms/firstwhite.gb new file mode 100644 index 0000000000000000000000000000000000000000..6d35132175512a3f349dcb4378cfebad2694af6d Binary files /dev/null and b/res/roms/firstwhite.gb differ diff --git a/res/roms/ld_r_r.gb b/res/roms/ld_r_r.gb new file mode 100644 index 0000000000000000000000000000000000000000..d497bfd1275361bc847fa94dc87b43729f180b5d Binary files /dev/null and b/res/roms/ld_r_r.gb differ diff --git a/res/roms/opus5.gb b/res/roms/opus5.gb new file mode 100644 index 0000000000000000000000000000000000000000..a3b8028a31c98b753f0b03d8b8faa2da4232ef14 Binary files /dev/null and b/res/roms/opus5.gb differ diff --git a/res/roms/rtc3test.gb b/res/roms/rtc3test.gb new file mode 100644 index 0000000000000000000000000000000000000000..06836967eb2927b31c99de201a44cfd709380268 Binary files /dev/null and b/res/roms/rtc3test.gb differ diff --git a/res/roms/special.gb b/res/roms/special.gb new file mode 100644 index 0000000000000000000000000000000000000000..ad3e9984f967b77b7ffdf768842ce3c04517d059 Binary files /dev/null and b/res/roms/special.gb differ diff --git a/src/gb.rs b/src/gb.rs index e918ba68414c40cf8a7aba37695b63136b5a7f81..a70e2cfe869cd57ad7774973a559ac63b890ea6e 100644 --- a/src/gb.rs +++ b/src/gb.rs @@ -8,6 +8,23 @@ use crate::{ #[cfg(feature = "wasm")] use wasm_bindgen::prelude::*; +/// Static data corresponding to the DMG boot ROM +/// allows freely using the emulator without external dependency. +pub const BOOT_DATA: [u8; 256] = [ + 49, 254, 255, 175, 33, 255, 159, 50, 203, 124, 32, 251, 33, 38, 255, 14, 17, 62, 128, 50, 226, + 12, 62, 243, 226, 50, 62, 119, 119, 62, 252, 224, 71, 17, 4, 1, 33, 16, 128, 26, 205, 149, 0, + 205, 150, 0, 19, 123, 254, 52, 32, 243, 17, 216, 0, 6, 8, 26, 19, 34, 35, 5, 32, 249, 62, 25, + 234, 16, 153, 33, 47, 153, 14, 12, 61, 40, 8, 50, 13, 32, 249, 46, 15, 24, 243, 103, 62, 100, + 87, 224, 66, 62, 145, 224, 64, 4, 30, 2, 14, 12, 240, 68, 254, 144, 32, 250, 13, 32, 247, 29, + 32, 242, 14, 19, 36, 124, 30, 131, 254, 98, 40, 6, 30, 193, 254, 100, 32, 6, 123, 226, 12, 62, + 135, 226, 240, 66, 144, 224, 66, 21, 32, 210, 5, 32, 79, 22, 32, 24, 203, 79, 6, 4, 197, 203, + 17, 23, 193, 203, 17, 23, 5, 32, 245, 34, 35, 34, 35, 201, 206, 237, 102, 102, 204, 13, 0, 11, + 3, 115, 0, 131, 0, 12, 0, 13, 0, 8, 17, 31, 136, 137, 0, 14, 220, 204, 110, 230, 221, 221, 217, + 153, 187, 187, 103, 99, 110, 14, 236, 204, 221, 220, 153, 159, 187, 185, 51, 62, 60, 66, 185, + 165, 185, 165, 66, 60, 33, 4, 1, 17, 168, 0, 26, 19, 190, 32, 254, 35, 125, 254, 52, 32, 245, + 6, 25, 120, 134, 35, 5, 32, 251, 134, 32, 254, 62, 1, 224, 80, +]; + #[cfg_attr(feature = "wasm", wasm_bindgen)] pub struct GameBoy { cpu: Cpu, @@ -23,6 +40,10 @@ impl GameBoy { GameBoy { cpu: cpu } } + pub fn pc(&self) -> u16 { + self.cpu.pc() + } + pub fn clock(&mut self) -> u8 { let cycles = self.cpu_clock(); self.ppu_clock(cycles); @@ -37,18 +58,34 @@ impl GameBoy { self.ppu().clock(cycles) } - pub fn load_rom(&mut self, path: &str) { + pub fn load_rom(&mut self, data: &[u8]) { + self.cpu.mmu().write_rom(0x0000, data); + } + + pub fn load_rom_file(&mut self, path: &str) { let data = read_file(path); - self.cpu.mmu().write_rom(0x0000, &data); + self.load_rom(&data); } - pub fn load_boot(&mut self, path: &str) { + pub fn load_boot(&mut self, data: &[u8]) { + self.cpu.mmu().write_boot(0x0000, data); + } + + pub fn load_boot_file(&mut self, path: &str) { let data = read_file(path); - self.cpu.mmu().write_boot(0x0000, &data); + self.load_boot(&data); } pub fn load_boot_default(&mut self) { - self.load_boot("./res/dmg_rom.bin"); + self.load_boot_file("./res/dmg_rom.bin"); + } + + pub fn load_boot_static(&mut self) { + self.load_boot(&BOOT_DATA); + } + + pub fn frame_buffer_eager(&mut self) -> Vec<u8> { + self.frame_buffer().to_vec() } } diff --git a/src/mmu.rs b/src/mmu.rs index 4eab0ec249be6eca30538348832f7c7b81275e05..0a1be916b6c2d5246d80ae0383075345308913f8 100644 --- a/src/mmu.rs +++ b/src/mmu.rs @@ -94,15 +94,16 @@ impl Mmu { match addr & 0xf000 { // BOOT (256 B) + ROM0 (4 KB/16 KB) 0x0000 => { - println!("Writing to BOOT") + self.rom[addr as usize] = value; + println!("Writing to BOOT at 0x{:04x}", addr) } // ROM0 (12 KB/16 KB) 0x1000 | 0x2000 | 0x3000 => { - println!("Writing to ROM 0"); + println!("Writing to ROM 0 at 0x{:04x}", addr); } // ROM1 (Unbanked) (16 KB) 0x4000 | 0x5000 | 0x6000 | 0x7000 => { - println!("Writing to ROM 1"); + println!("Writing to ROM 1 at 0x{:04x}", addr); } // Graphics: VRAM (8 KB) 0x8000 | 0x9000 => {